├── .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 | --------------------------------------------------------------------------------