├── .gitignore
├── index.html
├── lib
├── index.ts
├── view.ts
├── signal.ts
└── render.ts
├── vite.config.ts
├── README.md
├── package.json
├── app.ts
└── tsconfig.json
/.gitignore:
--------------------------------------------------------------------------------
1 | .vite
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/lib/index.ts:
--------------------------------------------------------------------------------
1 | export {signal, effect} from './signal';
2 | export {render} from './render';
3 | export {View, For, When} from './view';
4 |
5 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite';
2 | import typescript from '@rollup/plugin-typescript';
3 |
4 | export default defineConfig({
5 | plugins: [typescript()],
6 | });
7 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Revolt
2 |
3 | Revolt is a toy framework and signals implementation to explore different rendering ideas.
4 |
5 | ## How to try?
6 |
7 | ```bash
8 | npm i -g vite
9 | vite
10 | ```
11 |
12 | ## License
13 |
14 | MIT
15 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "revolt",
3 | "version": "1.0.0",
4 | "description": "Revolt is a toy framework and signals implementation to explore different rendering ideas.",
5 | "main": "index.js",
6 | "directories": {
7 | "lib": "lib"
8 | },
9 | "scripts": {
10 | "test": "echo \"Error: no test specified\" && exit 1"
11 | },
12 | "author": "",
13 | "license": "ISC",
14 | "devDependencies": {
15 | "@rollup/plugin-typescript": "^12.1.2",
16 | "tslib": "^2.8.1",
17 | "typescript": "^5.7.3",
18 | "vite": "^6.0.7"
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/lib/view.ts:
--------------------------------------------------------------------------------
1 | import { ReadableSignal } from "./signal";
2 |
3 | export type Binding = string | (() => string);
4 | export type EventListener = (event: GlobalEventHandlersEventMap[K]) => void;
5 |
6 | export interface When {
7 | condition: ReadableSignal;
8 | then: View;
9 | else?: View;
10 | }
11 |
12 | export interface For {
13 | collection: ReadableSignal;
14 | items: (item: T, index: number) => ViewNode;
15 | }
16 |
17 | export interface ElementConfig {
18 | name: keyof HTMLElementTagNameMap;
19 | attributes?: Record false|string)>;
20 | children?: View;
21 | events?: {[key in keyof GlobalEventHandlersEventMap]?: EventListener};
22 | ref?: (node: Element) => void;
23 | }
24 |
25 | export type ViewNode = Binding | ElementConfig | When | For;
26 |
27 | export type View = ViewNode | ViewNode[] | View[];
28 |
29 | export type Component = (() => View);
30 |
31 | export const isElement = (node: any) => {
32 | return node.name !== undefined;
33 | };
34 |
35 | export const isDynamicBinding = (binding: string|(() => false|string)): binding is (() => false|string) => {
36 | return typeof binding === 'function';
37 | };
38 |
39 | export const isConditional = (node: any): node is When => {
40 | return node.condition !== undefined;
41 | };
42 |
43 | export const isIterator = (node: any): node is For => {
44 | return node.collection !== undefined;
45 | };
46 |
--------------------------------------------------------------------------------
/lib/signal.ts:
--------------------------------------------------------------------------------
1 | // Ryan's implementation from https://dev.to/ryansolid/building-a-reactive-library-from-scratch-1i0p
2 |
3 | const context: any = [];
4 |
5 | export type ReadableSignal = () => T;
6 |
7 | export interface WritableSignal extends ReadableSignal {
8 | set(value: T): void;
9 | }
10 |
11 | export type Effect = () => void;
12 |
13 | export function signal(value: T): WritableSignal {
14 | const subscriptions = new Set();
15 |
16 | const read = (): T => {
17 | const running = context[context.length - 1];
18 | if (running) {
19 | subscriptions.add(running);
20 | running.dependencies.add(subscriptions);
21 | }
22 | return value;
23 | };
24 |
25 | const set = (nextValue: T) => {
26 | value = nextValue;
27 | for (const sub of [...subscriptions]) {
28 | sub.execute();
29 | }
30 | };
31 |
32 | (read as any).set = set;
33 | return read as WritableSignal;
34 | }
35 |
36 | function cleanup(running: any) {
37 | for (const dep of running.dependencies) {
38 | dep.delete(running);
39 | }
40 | running.dependencies.clear();
41 | }
42 |
43 | export function effect(fn: Effect) {
44 | const execute = () => {
45 | cleanup(running);
46 | context.push(running);
47 | try {
48 | fn();
49 | } finally {
50 | context.pop();
51 | }
52 | };
53 |
54 | const running: any = {
55 | execute,
56 | dependencies: new Set()
57 | };
58 |
59 | execute();
60 | }
--------------------------------------------------------------------------------
/app.ts:
--------------------------------------------------------------------------------
1 | import { signal, render, View, For } from "./lib";
2 |
3 | const Massive = (): View => {
4 | const arr = new Array(10000).fill('0');
5 |
6 | return {
7 | name: 'div',
8 | children: arr
9 | }
10 | };
11 |
12 | const Checkbox = (checked: () => string | false): View => {
13 | return {
14 | name: "input",
15 | attributes: {
16 | type: "checkbox",
17 | checked
18 | },
19 | };
20 | };
21 |
22 | const TodoApp = (): View => {
23 | const todos = signal(["Buy milk", "Create a framework"]);
24 | let inputElement: HTMLInputElement | undefined;
25 |
26 | const addTodo = () => {
27 | if (!inputElement) {
28 | return;
29 | }
30 | todos.set([...todos(), inputElement.value]);
31 | inputElement.value = "";
32 | };
33 |
34 | return [
35 | {
36 | name: "h1",
37 | children: "Todo App",
38 | },
39 | {
40 | name: "input",
41 | attributes: {
42 | type: "text",
43 | },
44 | ref(input: Element) {
45 | inputElement = input as HTMLInputElement;
46 | },
47 | events: {
48 | keydown(e: Event) {
49 | const event = e as KeyboardEvent;
50 | if (event.code === "Enter") {
51 | addTodo();
52 | }
53 | }
54 | },
55 | },
56 | {
57 | name: "button",
58 | children: "Add todo",
59 | events: {
60 | click: addTodo,
61 | },
62 | },
63 | {
64 | name: "ul",
65 | children: {
66 | collection: todos,
67 | items(item: string) {
68 | return {
69 | name: "li",
70 | children: item,
71 | events: {
72 | click() {
73 | todos.set(todos().filter((t) => t !== item));
74 | },
75 | },
76 | };
77 | },
78 | }
79 | },
80 | ];
81 | };
82 |
83 | const App = (): View => {
84 | const state = signal(0);
85 | const bgColor = signal("red");
86 |
87 | setInterval(() => {
88 | state.set(state() + 1);
89 | bgColor.set(state() % 2 === 0 ? "red" : "blue");
90 | }, 1000);
91 |
92 | return {
93 | name: "section",
94 | children: [
95 | TodoApp(),
96 | {
97 | name: "div",
98 | attributes: {
99 | id: "app",
100 | style: () =>
101 | `background: ${bgColor()}; width: 70px; height: 50px; color: white; text-align: center; line-height: 50px;`,
102 | },
103 | children: [() => `Timer: ${state()}`],
104 | },
105 | Checkbox(() => (state() % 2 === 0 ? "checked" : false)),
106 | {
107 | condition: () => state() % 2 === 0,
108 | then: Massive(),
109 | else: "Odd",
110 | },
111 | ],
112 | events: {
113 | click() {
114 | state.set(0);
115 | },
116 | },
117 | };
118 | };
119 |
120 | render(App(), document.body);
121 |
--------------------------------------------------------------------------------
/lib/render.ts:
--------------------------------------------------------------------------------
1 | import { effect } from "./signal";
2 | import {
3 | ElementConfig,
4 | For,
5 | isConditional,
6 | isDynamicBinding,
7 | isIterator,
8 | View,
9 | When,
10 | EventListener
11 | } from "./view";
12 |
13 | export const render = (view: View, root: Element): Node | Node[] => {
14 | if (isConditional(view)) {
15 | return renderCondition(view, root);
16 | }
17 | if (isIterator(view)) {
18 | return renderIterator(view, root);
19 | }
20 | if (view instanceof Array) {
21 | const result: Node[] = [];
22 | for (const child of view) {
23 | result.push(render(child, root) as Node);
24 | }
25 | return result;
26 | }
27 | if (typeof view === "string") {
28 | const node = document.createTextNode(view);
29 | root.append(node);
30 | return node;
31 | }
32 | if (typeof view === "function") {
33 | return renderDynamicText(view, root);
34 | }
35 | return renderElement(view, root);
36 | };
37 |
38 | const renderDynamicText = (view: () => string, root: Element) => {
39 | const node = document.createTextNode(view());
40 | effect(() => {
41 | const text = view();
42 | node.textContent = text;
43 | });
44 | root.append(node);
45 | return node;
46 | };
47 |
48 | const renderCondition = (view: When, root: Element) => {
49 | let dom: Node | Node[] | undefined;
50 | effect(() => {
51 | const result = view.condition();
52 | if (dom) {
53 | destroy(dom);
54 | }
55 | if (result) {
56 | dom = render(view.then, root);
57 | } else if (view.else) {
58 | dom = render(view.else, root);
59 | }
60 | });
61 | return dom ?? [];
62 | };
63 |
64 | const renderIterator = (view: For, root: Element) => {
65 | let collection = view.collection();
66 | let result: Node | Node[] | undefined;
67 | effect(() => {
68 | collection = view.collection();
69 | if (result) {
70 | destroy(result);
71 | }
72 | result = render(collection.map(view.items), root);
73 | });
74 | return result ?? [];
75 | };
76 |
77 | const renderElement = (view: ElementConfig, root: Element) => {
78 | const element = document.createElement(view.name);
79 | for (const attribute in view.attributes) {
80 | const binding = view.attributes[attribute];
81 | if (isDynamicBinding(binding)) {
82 | effect(() => {
83 | const value = binding();
84 | if (value === false) {
85 | element.removeAttribute(attribute);
86 | return;
87 | }
88 | element.setAttribute(attribute, value);
89 | });
90 | continue;
91 | }
92 | element.setAttribute(attribute, binding);
93 | }
94 | for (const event in view.events) {
95 | element.addEventListener(event, view.events[event as keyof GlobalEventHandlersEventMap] as EventListener);
96 | }
97 | (element as any).view = view;
98 | root.append(element);
99 | if (view.children) {
100 | render(view.children, element);
101 | }
102 | if (view.ref) {
103 | view.ref(element);
104 | }
105 | return element;
106 | };
107 |
108 | const destroy = (node: Node | Node[]) => {
109 | if (node instanceof Array) {
110 | for (const child of node) {
111 | destroy(child);
112 | }
113 | } else {
114 | node.parentElement?.removeChild(node);
115 | const view = (node as any)?.view;
116 | if (!view) {
117 | return;
118 | }
119 | for (const event in view.events) {
120 | node.removeEventListener(event, view.events[event]);
121 | }
122 | }
123 | };
124 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | /* Visit https://aka.ms/tsconfig to read more about this file */
4 |
5 | /* Projects */
6 | // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
7 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
8 | // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
9 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
10 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
11 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
12 |
13 | /* Language and Environment */
14 | "target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
15 | // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
16 | // "jsx": "preserve", /* Specify what JSX code is generated. */
17 | // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */
18 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
19 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
20 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
21 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
22 | // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
23 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
24 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
25 | // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
26 |
27 | /* Modules */
28 | "module": "esnext", /* Specify what module code is generated. */
29 | // "rootDir": "./", /* Specify the root folder within your source files. */
30 | // "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */
31 | // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
32 | // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
33 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
34 | // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
35 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */
36 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
37 | // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
38 | // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */
39 | // "rewriteRelativeImportExtensions": true, /* Rewrite '.ts', '.tsx', '.mts', and '.cts' file extensions in relative import paths to their JavaScript equivalent in output files. */
40 | // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */
41 | // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */
42 | // "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */
43 | // "noUncheckedSideEffectImports": true, /* Check side effect imports. */
44 | // "resolveJsonModule": true, /* Enable importing .json files. */
45 | // "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */
46 | // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */
47 |
48 | /* JavaScript Support */
49 | // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
50 | // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
51 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
52 |
53 | /* Emit */
54 | // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
55 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */
56 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
57 | // "sourceMap": true, /* Create source map files for emitted JavaScript files. */
58 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
59 | // "noEmit": true, /* Disable emitting files from a compilation. */
60 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
61 | // "outDir": "./", /* Specify an output folder for all emitted files. */
62 | // "removeComments": true, /* Disable emitting comments. */
63 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
64 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
65 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
66 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
67 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
68 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
69 | // "newLine": "crlf", /* Set the newline character for emitting files. */
70 | // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
71 | // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
72 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
73 | // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
74 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */
75 |
76 | /* Interop Constraints */
77 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
78 | // "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */
79 | // "isolatedDeclarations": true, /* Require sufficient annotation on exports so other tools can trivially generate declaration files. */
80 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
81 | "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
82 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
83 | "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
84 |
85 | /* Type Checking */
86 | "strict": true, /* Enable all strict type-checking options. */
87 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
88 | // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
89 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
90 | // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
91 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
92 | // "strictBuiltinIteratorReturn": true, /* Built-in iterators are instantiated with a 'TReturn' type of 'undefined' instead of 'any'. */
93 | // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
94 | // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
95 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
96 | // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
97 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
98 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
99 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
100 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
101 | // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
102 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
103 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
104 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
105 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
106 |
107 | /* Completeness */
108 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
109 | "skipLibCheck": true /* Skip type checking all .d.ts files. */
110 | }
111 | }
112 |
--------------------------------------------------------------------------------