├── src ├── jsx-dev-runtime.ts ├── tests │ ├── server │ │ ├── components.client.tsx │ │ ├── components.server.tsx │ │ ├── ssr-escaping.test.tsx │ │ ├── server-client-parity.test.tsx │ │ ├── ssr-render.test.tsx │ │ ├── ssr-basic.test.tsx │ │ ├── parity │ │ │ ├── ssr-parity-attributes-styles.test.tsx │ │ │ └── ssr-parity-probe.test.tsx │ │ └── ssr-props-events.test.tsx │ ├── custom-input.test.tsx │ ├── e2e.test.tsx │ ├── subcomponent-regeneration.test.tsx │ ├── abort-signal.test.tsx │ ├── connect-events.test.tsx │ ├── root-update.test.tsx │ ├── drummer.test.tsx │ ├── create-element.test.tsx │ ├── memo-props.test.tsx │ ├── closure.test.tsx │ ├── counter.test.tsx │ ├── root-reconcile.test.tsx │ ├── update-traversal.test.tsx │ ├── mutation.test.tsx │ ├── tabber-form.test.tsx │ ├── keyed-elements.test.tsx │ ├── prop-store-subscribe.test.tsx │ ├── style-observable.test.tsx │ ├── non-keyed-table-large.test.tsx │ ├── error-handling.test.tsx │ ├── keyed-table-large.test.tsx │ ├── create-list.test.tsx │ └── keyed-components.test.tsx ├── error.ts ├── bench │ ├── table │ │ ├── entries.ts │ │ ├── clear.bench.tsx │ │ ├── create-1000-rows.bench.tsx │ │ ├── create-10000-rows.bench.tsx │ │ ├── append-1000-rows.bench.tsx │ │ ├── update-every-10th-row.bench.tsx │ │ ├── swap-rows.bench.tsx │ │ └── frameworks │ │ │ ├── preact.tsx │ │ │ └── react.tsx │ ├── bench.utils.ts │ ├── render.bench.tsx │ └── update.bench.tsx ├── server │ ├── jsx-dev-runtime.ts │ └── jsx-runtime.ts ├── symbols.ts ├── signal.ts ├── types.ts ├── jsx-runtime.ts ├── server.ts └── channel.ts ├── .gitignore ├── .editorconfig ├── check-size.ts ├── deno.json ├── .zed └── settings.json ├── test └── utils.ts ├── tsconfig.json ├── LICENSE ├── playground ├── www │ ├── async.html │ ├── full.html │ └── table.html └── async.tsx └── AGENTS.md /src/jsx-dev-runtime.ts: -------------------------------------------------------------------------------- 1 | export * from "./jsx-runtime.ts"; 2 | -------------------------------------------------------------------------------- /src/tests/server/components.client.tsx: -------------------------------------------------------------------------------- 1 | /** @jsxImportSource ../../ */ 2 | 3 | export function App() { 4 | return
Hello, Radi!
; 5 | } 6 | -------------------------------------------------------------------------------- /src/tests/server/components.server.tsx: -------------------------------------------------------------------------------- 1 | /** @jsxImportSource ../../server */ 2 | 3 | export function App() { 4 | return
Hello, Radi!
; 5 | } 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .nyc_output/ 3 | 4 | .DS_Store 5 | .cache 6 | yarn-error.log 7 | 8 | .vscode/ 9 | /dist 10 | /.playwright-profile 11 | /npm 12 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | indent_style = space 8 | indent_size = 2 9 | end_of_line = lf 10 | insert_final_newline = true 11 | trim_trailing_whitespace = true 12 | 13 | [{package.json,package-lock.json}] 14 | indent_style = space 15 | 16 | [*.{yml,yaml}] 17 | indent_style = space 18 | -------------------------------------------------------------------------------- /src/error.ts: -------------------------------------------------------------------------------- 1 | export function bubbleError(error: any, target: Node, name?: string) { 2 | if ( 3 | target.dispatchEvent( 4 | new ErrorEvent("error", { 5 | error, 6 | bubbles: true, 7 | composed: true, 8 | cancelable: true, 9 | }), 10 | ) 11 | ) { 12 | console.error(name || target, error); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/bench/table/entries.ts: -------------------------------------------------------------------------------- 1 | export const entries = Object.entries({ 2 | vanilla: () => import("./frameworks/vanilla.tsx"), 3 | radi: () => import("./frameworks/radi.tsx"), 4 | // lit: () => import("./frameworks/lit.tsx"), 5 | preact: () => import("./frameworks/preact.tsx"), 6 | react: () => import("./frameworks/react.tsx"), 7 | // marko: () => import("./frameworks/marko.tsx"), 8 | redom: () => import("./frameworks/redom.tsx"), 9 | }); 10 | -------------------------------------------------------------------------------- /check-size.ts: -------------------------------------------------------------------------------- 1 | import * as esbuild from "npm:esbuild"; 2 | 3 | await esbuild.build({ 4 | entryPoints: ["src/client.ts", "src/server.ts"], 5 | "sourcemap": false, 6 | write: false, 7 | outdir: "out", 8 | "target": [ 9 | "esnext", 10 | ], 11 | "format": "esm", 12 | "bundle": true, 13 | "minify": true, 14 | "treeShaking": true, 15 | "platform": "browser", 16 | "color": true, 17 | "globalName": "BundledCode", 18 | "logLevel": "info", 19 | }); 20 | -------------------------------------------------------------------------------- /deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "radi", 3 | "version": "0.5.0", 4 | "tasks": { 5 | "dev": "deno -A npm:esbuild playground/*.tsx --jsx-import-source=preact --jsx=automatic --watch --servedir=playground/www --outdir=playground/www --bundle", 6 | "test": "deno -A --unstable-bundle @marcisbee/rion/test.cli --includes src", 7 | "bench": "deno -A --unstable-bundle @marcisbee/rion/bench.cli --includes src", 8 | "publish": "deno -A ./build.npm.ts && cd npm && npm publish --tag alpha" 9 | }, 10 | "exports": { 11 | "./*": "./src/*.ts" 12 | }, 13 | "imports": { 14 | "@marcisbee/rion": "jsr:@marcisbee/rion@^0.5.0" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/bench/table/clear.bench.tsx: -------------------------------------------------------------------------------- 1 | import { bench } from "@marcisbee/rion/bench"; 2 | import { interact, locator } from "@marcisbee/rion/locator"; 3 | 4 | import { entries } from "./entries.ts"; 5 | 6 | for (const [name, load] of entries) { 7 | bench(name, { 8 | async setup() { 9 | await load(); 10 | await locator("h1").getOne(); 11 | }, 12 | async beforeEach() { 13 | await interact(locator("#run")).click(); 14 | await locator("tbody > tr").nth(1000).getOne(); 15 | }, 16 | }, async () => { 17 | await interact(locator("#clear")).click(); 18 | await locator("tbody").not("tr").getOne(); 19 | }); 20 | } 21 | 22 | await bench.run(); 23 | -------------------------------------------------------------------------------- /src/bench/table/create-1000-rows.bench.tsx: -------------------------------------------------------------------------------- 1 | import { bench } from "@marcisbee/rion/bench"; 2 | import { interact, locator } from "@marcisbee/rion/locator"; 3 | 4 | import { entries } from "./entries.ts"; 5 | 6 | for (const [name, load] of entries) { 7 | bench(name, { 8 | async setup() { 9 | await load(); 10 | await locator("h1").getOne(); 11 | }, 12 | async afterEach() { 13 | await interact(locator("#clear")).click(); 14 | await locator("tbody").not("tr").getOne(); 15 | }, 16 | }, async () => { 17 | await interact(locator("#run")).click(); 18 | await locator("tbody > tr").nth(1000).getOne(); 19 | }); 20 | } 21 | 22 | await bench.run(); 23 | -------------------------------------------------------------------------------- /src/bench/table/create-10000-rows.bench.tsx: -------------------------------------------------------------------------------- 1 | import { bench } from "@marcisbee/rion/bench"; 2 | import { interact, locator } from "@marcisbee/rion/locator"; 3 | 4 | import { entries } from "./entries.ts"; 5 | 6 | for (const [name, load] of entries) { 7 | bench(name, { 8 | async setup() { 9 | await load(); 10 | await locator("h1").getOne(); 11 | }, 12 | async afterEach() { 13 | await interact(locator("#clear")).click(); 14 | await locator("tbody").not("tr").getOne(); 15 | }, 16 | }, async () => { 17 | await interact(locator("#runlots")).click(); 18 | await locator("tbody > tr").nth(10000).getOne(); 19 | }); 20 | } 21 | 22 | await bench.run(); 23 | -------------------------------------------------------------------------------- /src/server/jsx-dev-runtime.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Server JSX Dev Runtime for Radi. 3 | * 4 | * Thin re-export of the server JSX runtime factories so that tooling 5 | * expecting a separate *-jsx-dev-runtime entry can reference this path. 6 | * 7 | * Usage (TypeScript / bundler config): 8 | * { 9 | * "compilerOptions": { 10 | * "jsx": "react-jsxdev", 11 | * "jsxImportSource": "radi" 12 | * }, 13 | * "paths": { 14 | * "radi/jsx-dev-runtime": ["radi/src/server-jsx-dev-runtime.ts"] 15 | * } 16 | * } 17 | * 18 | * This file intentionally only re-exports the symbols. The server runtime 19 | * already includes a `jsxDEV` factory variant suitable for dev transforms. 20 | */ 21 | export * from "./jsx-runtime.ts"; 22 | -------------------------------------------------------------------------------- /src/bench/table/append-1000-rows.bench.tsx: -------------------------------------------------------------------------------- 1 | import { bench } from "@marcisbee/rion/bench"; 2 | import { interact, locator } from "@marcisbee/rion/locator"; 3 | 4 | import { entries } from "./entries.ts"; 5 | 6 | for (const [name, load] of entries) { 7 | bench(name, { 8 | async setup() { 9 | await load(); 10 | await locator("h1").getOne(); 11 | }, 12 | async beforeEach() { 13 | await interact(locator("#run")).click(); 14 | await locator("tbody > tr").nth(1000).getOne(); 15 | }, 16 | async afterEach() { 17 | await interact(locator("#clear")).click(); 18 | await locator("tbody").not("tr").getOne(); 19 | }, 20 | }, async () => { 21 | await interact(locator("#add")).click(); 22 | await locator("tbody > tr").nth(2000).getOne(); 23 | }); 24 | } 25 | 26 | await bench.run(); 27 | -------------------------------------------------------------------------------- /src/symbols.ts: -------------------------------------------------------------------------------- 1 | export const COMPONENT = Symbol("component"); 2 | export const REACTIVE_CHILDREN = Symbol("reactive_children"); 3 | export const TAIL = Symbol("tail"); 4 | export const RENDER_ID = Symbol("render_id"); 5 | export const RENDER = Symbol("render"); 6 | export const MEMO = Symbol("memo"); 7 | export const REACTIVE_ATTRIBUTES = Symbol("reactive_attributes"); 8 | export const TYPE = Symbol("type"); 9 | export const PROPS = Symbol("props"); 10 | export const INSTANCE = Symbol("instance"); 11 | export const UPDATE_ID = Symbol("update_id"); 12 | export const CLEANUP = Symbol("cleanup"); 13 | export const SINGLE_KEYED = Symbol("single_keyed"); 14 | export const KEY = Symbol("key"); 15 | export const NODE = Symbol("node"); 16 | export const KEY_MAP = Symbol("key_map"); 17 | export const ATTRS = Symbol("attrs"); 18 | export const KEYED = Symbol("keyed"); 19 | -------------------------------------------------------------------------------- /src/bench/table/update-every-10th-row.bench.tsx: -------------------------------------------------------------------------------- 1 | import { bench } from "@marcisbee/rion/bench"; 2 | import { interact, locator } from "@marcisbee/rion/locator"; 3 | 4 | import { entries } from "./entries.ts"; 5 | 6 | for (const [name, load] of entries) { 7 | bench(name, { 8 | async setup() { 9 | await load(); 10 | await locator("h1").getOne(); 11 | }, 12 | async beforeEach() { 13 | await interact(locator("#run")).click(); 14 | await locator("tbody > tr").nth(1000).getOne(); 15 | }, 16 | async afterEach() { 17 | await interact(locator("#clear")).click(); 18 | await locator("tbody").not("tr").getOne(); 19 | }, 20 | }, async () => { 21 | await interact(locator("#update")).click(); 22 | 23 | await locator("tbody > tr").nth(1).locate("td").nth(2).locate("a").hasText( 24 | " !!!", 25 | ).getOne(); 26 | }); 27 | } 28 | 29 | await bench.run(); 30 | -------------------------------------------------------------------------------- /.zed/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "lsp": { 3 | "deno": { 4 | "settings": { 5 | "deno": { 6 | "enable": true 7 | } 8 | } 9 | } 10 | }, 11 | "languages": { 12 | "JavaScript": { 13 | "language_servers": [ 14 | "deno", 15 | "!typescript-language-server", 16 | "!vtsls", 17 | "!eslint", 18 | "!biome" 19 | ], 20 | "formatter": "language_server" 21 | }, 22 | "TypeScript": { 23 | "language_servers": [ 24 | "deno", 25 | "!typescript-language-server", 26 | "!vtsls", 27 | "!eslint", 28 | "!biome" 29 | ], 30 | "formatter": "language_server" 31 | }, 32 | "TSX": { 33 | "language_servers": [ 34 | "deno", 35 | "!typescript-language-server", 36 | "!vtsls", 37 | "!eslint", 38 | "!biome" 39 | ], 40 | "formatter": "language_server" 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /test/utils.ts: -------------------------------------------------------------------------------- 1 | import { createRoot } from "../src/client.ts"; 2 | 3 | export function mount( 4 | element: Parameters["render"]>[0], 5 | parent: Parameters[0], 6 | ) { 7 | const promise = new Promise((resolve) => { 8 | const onConnect = () => resolve(element); 9 | 10 | // Attach listener before calling render so we catch synchronous "connect" events 11 | (element as EventTarget).addEventListener("connect", onConnect, { 12 | once: true, 13 | passive: true, 14 | }); 15 | 16 | // If the element is already connected, resolve immediately and remove the listener 17 | if ((element as Node).isConnected) { 18 | (element as EventTarget).removeEventListener("connect", onConnect, { 19 | capture: true, 20 | }); 21 | onConnect(); 22 | } 23 | 24 | const { render } = createRoot(parent); 25 | render(element); 26 | }); 27 | 28 | return promise; 29 | } 30 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "lib": ["DOM", "DOM.Iterable", "ESNext", "deno.ns"], 5 | "typeRoots": ["node_modules/@types", "types"], 6 | "allowJs": false, 7 | "skipLibCheck": true, 8 | "esModuleInterop": false, 9 | "experimentalDecorators": true, 10 | "emitDecoratorMetadata": true, 11 | "allowSyntheticDefaultImports": true, 12 | "strict": true, 13 | "forceConsistentCasingInFileNames": true, 14 | "module": "esnext", 15 | "moduleResolution": "Node", 16 | "resolveJsonModule": true, 17 | "isolatedModules": true, 18 | "noEmit": true, 19 | "jsx": "react-jsx", 20 | "jsxImportSource": "radi", 21 | "allowImportingTsExtensions": true, 22 | "baseUrl": ".", 23 | "paths": { 24 | "radi": ["."], 25 | "radi/jsx-runtime": ["src/jsx-runtime.ts"], 26 | "radi/jsx-dev-runtime": ["src/jsx-dev-runtime.ts"] 27 | } 28 | }, 29 | "exclude": ["node_modules"], 30 | "include": ["**/*.ts", "**/*.tsx", "**/*.js"] 31 | } 32 | -------------------------------------------------------------------------------- /src/bench/table/swap-rows.bench.tsx: -------------------------------------------------------------------------------- 1 | import { bench } from "@marcisbee/rion/bench"; 2 | import { interact, locator } from "@marcisbee/rion/locator"; 3 | 4 | import { entries } from "./entries.ts"; 5 | 6 | for (const [name, load] of entries) { 7 | bench(name, { 8 | async setup() { 9 | await load(); 10 | await locator("h1").getOne(); 11 | }, 12 | async beforeEach() { 13 | await interact(locator("#run")).click(); 14 | await locator("tbody > tr").nth(1000).getOne(); 15 | }, 16 | async afterEach() { 17 | await interact(locator("#clear")).click(); 18 | await locator("tbody").not("tr").getOne(); 19 | }, 20 | }, async () => { 21 | await interact(locator("#swaprows")).click(); 22 | 23 | // Wait for both swapped positions to exist (indexes 2 and 999) ensuring full table rendered 24 | await locator("tbody > tr").nth(2).locate("td").hasText("999").getOne(); 25 | await locator("tbody > tr").nth(999).locate("td").hasText("2").getOne(); 26 | }); 27 | } 28 | 29 | await bench.run(); 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Radi.js 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/bench/bench.utils.ts: -------------------------------------------------------------------------------- 1 | function getElement(xpath: string) { 2 | return document.evaluate( 3 | xpath, 4 | document, 5 | null, 6 | XPathResult.FIRST_ORDERED_NODE_TYPE, 7 | null, 8 | ).singleNodeValue as Element | null; 9 | } 10 | 11 | export function waitForXPath( 12 | xpath: string, 13 | timeoutMs: number = 4000, 14 | ): Promise { 15 | const el = getElement(xpath) as any; 16 | if (el?.isConnected) { 17 | return Promise.resolve(el); 18 | } 19 | 20 | // deno-lint-ignore no-async-promise-executor 21 | return new Promise(async (resolve, reject) => { 22 | // Double-check immediately to avoid races 23 | const immediate = getElement(xpath) as any; 24 | if (immediate?.isConnected) { 25 | resolve(immediate); 26 | return; 27 | } 28 | 29 | let cancelled = false; 30 | const timer = setTimeout(() => { 31 | if (cancelled) return; 32 | cancelled = true; 33 | reject(new Error(`Timeout waiting for XPath: ${xpath}`)); 34 | }, timeoutMs); 35 | 36 | try { 37 | while (!cancelled) { 38 | const candidate = getElement(xpath) as any; 39 | if (candidate?.isConnected) { 40 | if (!cancelled) { 41 | cancelled = true; 42 | clearTimeout(timer); 43 | resolve(candidate); 44 | } 45 | return; 46 | } 47 | await new Promise((res) => requestAnimationFrame(res as any)); 48 | } 49 | } catch (err) { 50 | if (!cancelled) { 51 | cancelled = true; 52 | clearTimeout(timer); 53 | reject(err); 54 | } 55 | } 56 | }); 57 | } 58 | -------------------------------------------------------------------------------- /src/signal.ts: -------------------------------------------------------------------------------- 1 | import { update } from "./client.ts"; 2 | 3 | export function createSignal(initialValue: T) { 4 | let value = initialValue; 5 | 6 | const subscribers: Node[] = []; 7 | 8 | const addSubscriber = (node: Node) => { 9 | if (subscribers.indexOf(node) === -1) subscribers.push(node); 10 | }; 11 | 12 | const removeSubscriber = (node: Node) => { 13 | const idx = subscribers.indexOf(node); 14 | if (idx !== -1) subscribers.splice(idx, 1); 15 | }; 16 | 17 | const attachNodeListeners = (node: Node) => { 18 | const onConnect = (e: Event) => { 19 | e.stopImmediatePropagation(); 20 | addSubscriber(node); 21 | }; 22 | const onDisconnect = (e: Event) => { 23 | e.stopImmediatePropagation(); 24 | removeSubscriber(node); 25 | }; 26 | node.addEventListener("connect", onConnect); 27 | node.addEventListener("disconnect", onDisconnect); 28 | }; 29 | 30 | return ((...args: [] | [T | ((map: T) => any)]) => { 31 | if (args.length === 0) { 32 | return value; 33 | } 34 | 35 | const newValue = args[0]; 36 | 37 | if (newValue instanceof Node) { 38 | attachNodeListeners(newValue); 39 | return value; 40 | } 41 | 42 | if (typeof newValue === "function") { 43 | const mapper = newValue as (map: T) => T; 44 | return (el: Node): any => { 45 | attachNodeListeners(el); 46 | return mapper(value); 47 | }; 48 | } 49 | 50 | try { 51 | value = newValue as T; 52 | return value; 53 | } finally { 54 | for (const target of subscribers) { 55 | update(target); 56 | } 57 | } 58 | }) as { 59 | (): T; 60 | (newValue: T | ((map: T) => any)): T; 61 | }; 62 | } 63 | -------------------------------------------------------------------------------- /src/bench/render.bench.tsx: -------------------------------------------------------------------------------- 1 | import { bench } from "@marcisbee/rion/bench"; 2 | import { locator } from "@marcisbee/rion/locator"; 3 | import { createElement as createElementReact } from "npm:react"; 4 | import { createRoot as createRootReact } from "npm:react-dom/client"; 5 | 6 | import { createRoot } from "../client.ts"; 7 | 8 | bench( 9 | "innerHTML", 10 | { 11 | setup() { 12 | document.body.innerHTML = ""; 13 | document.body.innerHTML = "

Hello bench

"; 14 | }, 15 | }, 16 | async () => { 17 | await locator("h1").hasText("Hello bench").getOne(); 18 | }, 19 | ); 20 | 21 | { 22 | let root: ReturnType | null = null; 23 | 24 | function Simple() { 25 | return

Hello bench

; 26 | } 27 | 28 | bench( 29 | "radi", 30 | { 31 | setup() { 32 | document.body.innerHTML = ""; 33 | }, 34 | }, 35 | async () => { 36 | root = createRoot(document.body); 37 | root.render(); 38 | await locator("h1").hasText("Hello bench").getOne(); 39 | root?.unmount(); 40 | root = null; 41 | }, 42 | ); 43 | } 44 | 45 | { 46 | let root: ReturnType | null = null; 47 | 48 | function SimpleReact() { 49 | return createElementReact("h1", null, "Hello bench"); 50 | } 51 | 52 | bench( 53 | "react", 54 | { 55 | setup() { 56 | document.body.innerHTML = ""; 57 | }, 58 | }, 59 | async () => { 60 | root = createRootReact(document.body); 61 | root.render(createElementReact(SimpleReact)); 62 | await locator("h1").hasText("Hello bench").getOne(); 63 | root?.unmount(); 64 | root = null; 65 | }, 66 | ); 67 | } 68 | 69 | await bench.run(); 70 | -------------------------------------------------------------------------------- /playground/www/async.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Playground 7 | 8 | 9 | 10 | 55 | 56 | 57 | -------------------------------------------------------------------------------- /playground/www/full.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Playground 7 | 8 | 9 | 10 | 55 | 56 | 57 | -------------------------------------------------------------------------------- /playground/www/table.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Playground 7 | 8 | 9 | 10 | 55 | 56 | 57 | -------------------------------------------------------------------------------- /src/tests/custom-input.test.tsx: -------------------------------------------------------------------------------- 1 | import { assert, test } from "@marcisbee/rion/test"; 2 | import { mount } from "../../test/utils.ts"; 3 | import { update } from "../client.ts"; 4 | 5 | /** 6 | * CustomInputTest component renders a controlled text input whose value is mirrored 7 | * in a sibling span. The input's displayed value is driven by internal state that 8 | * updates on every input event, triggering a re-render. 9 | * 10 | * Props: 11 | * - defaultValue?: string - initial string shown in the input and mirror. 12 | * 13 | * @param this Host HTMLElement (used for update triggering). 14 | * @param props Reactive props accessor containing optional defaultValue. 15 | */ 16 | function CustomInputTest( 17 | this: HTMLElement, 18 | props: JSX.Props<{ defaultValue?: string }>, 19 | ) { 20 | let value = props().defaultValue ?? ""; 21 | return ( 22 |
23 | value} 26 | oninput={(e) => { 27 | value = (e.target as HTMLInputElement).value; 28 | update(this); 29 | }} 30 | /> 31 | {() => value} 32 |
33 | ); 34 | } 35 | 36 | /** 37 | * mirrors typed value 38 | * Ensures that typing (simulated by dispatching input) updates the mirrored span. 39 | */ 40 | test("mirrors typed value", async () => { 41 | const root = await mount( 42 | , 43 | document.body, 44 | ); 45 | const input = root.querySelector("input") as HTMLInputElement; 46 | const mirror = root.querySelector(".mirror")!; 47 | assert.equal(mirror.textContent, "Hey"); 48 | 49 | input.value = "World"; 50 | input.dispatchEvent(new Event("input", { bubbles: true })); 51 | await Promise.resolve(); 52 | assert.equal(mirror.textContent, "World"); 53 | }); 54 | 55 | await test.run(); 56 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A reactive function that produces new child(ren) when invoked with the parent element. 3 | * Returned value may be a single child or an array of children which will be normalized. 4 | */ 5 | export type ReactiveGenerator = (parent: Element) => Child | Child[]; 6 | 7 | /** 8 | * Subscribable source of reactive values. 9 | * Implementations call the provided listener whenever the value changes. 10 | * The return value of subscribe may be: 11 | * - void (no cleanup needed) 12 | * - a function () => void to cleanup 13 | * - an object with an unsubscribe(): void method 14 | */ 15 | export interface Subscribable { 16 | subscribe( 17 | fn: (value: T) => void, 18 | ): void | (() => void) | { unsubscribe(): void }; 19 | } 20 | 21 | /** 22 | * Primitive or structured child value accepted by the framework's element builders. 23 | * Includes reactive generators and subscribable stores. 24 | */ 25 | export type Child = 26 | | string 27 | | number 28 | | boolean 29 | | null 30 | | undefined 31 | | Node 32 | | { node: unknown } // structural match for UniversalNode (server renderer) 33 | | Child[] 34 | | ReactiveGenerator 35 | | Subscribable; 36 | 37 | /** 38 | * Type guard to determine whether an arbitrary value is a Subscribable. 39 | * Checks for a non-null object with a function subscribe property. 40 | */ 41 | export function isSubscribable(value: unknown): value is Subscribable { 42 | return ( 43 | !!value && 44 | typeof value === "object" && 45 | typeof (value as { subscribe?: unknown }).subscribe === "function" 46 | ); 47 | } 48 | 49 | /** 50 | * Type guard to determine if a value is a ReactiveGenerator. 51 | * Uses typeof function check; additional heuristics can be added later. 52 | */ 53 | export function isReactiveGenerator( 54 | value: unknown, 55 | ): value is ReactiveGenerator { 56 | return typeof value === "function"; 57 | } 58 | 59 | /** 60 | * Normalize a possible reactive child into an array for uniform processing. 61 | * Does not perform Node creation; higher-level code handles conversion. 62 | */ 63 | export function toChildArray(child: Child | Child[]): Child[] { 64 | return Array.isArray(child) ? child : [child]; 65 | } 66 | -------------------------------------------------------------------------------- /src/jsx-runtime.ts: -------------------------------------------------------------------------------- 1 | import { createElement, Fragment as RadiFragment } from "./client.ts"; 2 | 3 | type RadiChild = any; // Accept the permissive child types used by createElement. 4 | 5 | interface SourceInfo { 6 | fileName?: string; 7 | lineNumber?: number; 8 | columnNumber?: number; 9 | } 10 | 11 | type Props = Record | null; 12 | 13 | /** 14 | * Convert the props object produced by the TS transform into arguments 15 | * for Radi's createElement factory. 16 | */ 17 | function prepare(type: any, props: Props, key?: RadiChild): RadiChild { 18 | const p = props ? { ...props } : {}; 19 | if (key != null) { 20 | // Store key on props so user code could access it 21 | (p as any).key = key; 22 | } 23 | const children = (p as any).children; 24 | delete (p as any).children; 25 | 26 | if (children === undefined) { 27 | return createElement(type, p); 28 | } 29 | if (Array.isArray(children)) { 30 | return createElement(type, p, ...children); 31 | } 32 | return createElement(type, p, children); 33 | } 34 | 35 | /** 36 | * Single child form. 37 | */ 38 | export function jsx( 39 | type: any, 40 | props: Record, 41 | key?: RadiChild, 42 | ): RadiChild { 43 | return prepare(type, props, key); 44 | } 45 | 46 | /** 47 | * Multiple children form. 48 | */ 49 | export function jsxs( 50 | type: any, 51 | props: Record, 52 | key?: RadiChild, 53 | ): RadiChild { 54 | return prepare(type, props, key); 55 | } 56 | 57 | /** 58 | * Development form (mirrors React's signature for compatibility if consumed directly). 59 | * NOTE: For full automatic dev runtime support use radi/jsx-dev-runtime.ts which exports jsxDEV. 60 | */ 61 | export function jsxDEV( 62 | type: any, 63 | props: Record, 64 | key: RadiChild | undefined, 65 | isStaticChildren: boolean, 66 | source?: SourceInfo, 67 | self?: any, 68 | ): RadiChild { 69 | const p = props ? { ...props } : {}; 70 | if (key != null) { 71 | (p as any).key = key; 72 | } 73 | if (source) { 74 | // Attach debug metadata (non-enumerable to avoid attribute emission if possible) 75 | Object.defineProperty(p, "__source", { value: source, enumerable: false }); 76 | } 77 | if (self) { 78 | Object.defineProperty(p, "__self", { value: self, enumerable: false }); 79 | } 80 | return prepare(type, p, key); 81 | } 82 | 83 | /** 84 | * Fragment export required by the JSX transform. 85 | */ 86 | export const Fragment = RadiFragment; 87 | 88 | // Optional default export for some bundlers / interop use-cases. 89 | export default { jsx, jsxs, jsxDEV, Fragment }; 90 | -------------------------------------------------------------------------------- /src/tests/e2e.test.tsx: -------------------------------------------------------------------------------- 1 | import { assert, clock, test } from "@marcisbee/rion/test"; 2 | import { mount } from "../../test/utils.ts"; 3 | import { createKey, suspend, Suspense, unsuspend, update } from "../client.ts"; 4 | 5 | test("suspense + key", async () => { 6 | function Child(this: HTMLElement, props: JSX.Props<{ value: string }>) { 7 | let data = 42; 8 | 9 | suspend(this); 10 | 11 | new Promise((resolve) => setTimeout(resolve, 200)) 12 | .then(() => { 13 | data = 100; 14 | update(this); 15 | unsuspend(this); 16 | }); 17 | 18 | return () =>
{data} : {props().value}
; 19 | } 20 | 21 | let value = ""; 22 | function Parent() { 23 | return ( 24 |
25 | Loading..}> 26 |
27 | {() => value && createKey(() => , value)} 28 |
29 |
30 |
31 | ); 32 | } 33 | 34 | const root = await mount(, document.body); 35 | 36 | assert.snapshot.html( 37 | root, 38 | ` 39 | 40 |
41 | 42 | 43 | 44 |
45 | 46 |
47 |
48 | 49 |
50 |
51 |
52 | `, 53 | ); 54 | 55 | value = "first"; 56 | update(root); 57 | 58 | assert.snapshot.html( 59 | root, 60 | ` 61 | 62 |
63 | 64 | 65 | 66 |
67 | 68 |
42 : first
69 |
70 |
71 | Loading.. 72 |
73 |
74 |
75 | `, 76 | ); 77 | 78 | await clock.fastForward(200); 79 | await Promise.resolve(); 80 | 81 | assert.snapshot.html( 82 | root, 83 | ` 84 | 85 |
86 | 87 | 88 | 89 |
90 | 91 |
100 : first
92 |
93 |
94 | 95 |
96 |
97 |
98 | `, 99 | ); 100 | }); 101 | 102 | await test.run(); 103 | -------------------------------------------------------------------------------- /src/tests/subcomponent-regeneration.test.tsx: -------------------------------------------------------------------------------- 1 | import { assert, test } from "@marcisbee/rion/test"; 2 | import { mount } from "../../test/utils.ts"; 3 | import { update } from "../client.ts"; 4 | 5 | /** 6 | * SubValue 7 | * Displays a numeric value passed via props and exposes it reactively. 8 | * 9 | * Props: 10 | * - value: number 11 | * 12 | * The value is wrapped in a reactive accessor so that regenerating the parent 13 | * component with a new random value updates the displayed text. 14 | * 15 | * @param props Reactive props accessor containing a numeric value. 16 | * @returns JSX heading element showing the value. 17 | */ 18 | function SubValue(props: JSX.Props<{ value: number }>) { 19 | return

Value: {() => props().value}

; 20 | } 21 | 22 | /** 23 | * Regenerator 24 | * Parent component that returns a reactive function producing a new 25 | * `SubValue` instance with a freshly generated random number each time 26 | * `update(parent)` is invoked. 27 | * 28 | * @returns Reactive function returning a SubValue child with random value. 29 | */ 30 | function Regenerator(this: HTMLElement) { 31 | return () => ; 32 | } 33 | 34 | /** 35 | * prop-regenerates 36 | * Confirms that successive parent updates produce different random values. 37 | * Retries a limited number of times in the extremely unlikely event of 38 | * identical random outputs. 39 | */ 40 | test("prop-regenerates", async () => { 41 | const root = await mount(, document.body); 42 | const heading = root.querySelector(".sub2-value") as HTMLElement; 43 | assert.exists(heading); 44 | 45 | const first = heading.textContent!; 46 | assert.true(/Value:\s*\d\.\d+/.test(first)); 47 | 48 | const maxAttempts = 5; 49 | let attempt = 0; 50 | let changed = false; 51 | while (attempt < maxAttempts && !changed) { 52 | update(root); 53 | await Promise.resolve(); 54 | const current = heading.textContent!; 55 | if (current !== first) { 56 | changed = true; 57 | } else { 58 | attempt++; 59 | } 60 | } 61 | 62 | assert.true(changed, "Random value should change within attempts"); 63 | assert.match(heading.textContent, /Value:\s*\d\.\d+/); 64 | }); 65 | 66 | /** 67 | * stable-before-update 68 | * Ensures that without invoking `update`, the rendered random value 69 | * remains stable (no spontaneous change). 70 | */ 71 | test("stable-before-update", async () => { 72 | const root = await mount(, document.body); 73 | const heading = root.querySelector(".sub2-value") as HTMLElement; 74 | const initial = heading.textContent!; 75 | await Promise.resolve(); // allow any microtasks 76 | const again = heading.textContent!; 77 | assert.equal(initial, again); 78 | }); 79 | 80 | await test.run(); 81 | -------------------------------------------------------------------------------- /src/tests/abort-signal.test.tsx: -------------------------------------------------------------------------------- 1 | /// 2 | import { assert, test } from "@marcisbee/rion/test"; 3 | 4 | import { createAbortSignal, createRoot } from "../client.ts"; 5 | 6 | /** 7 | * Abortable component registers an abort event listener bound to its lifecycle. 8 | * When the hosting element is removed from the DOM, the underlying `AbortSignal` 9 | * dispatches an "abort" event which we capture and log via the shared `events` array. 10 | * 11 | * Props: 12 | * - label?: string - optional label used to namespace the logged event. 13 | * 14 | * The component returns a simple div so that tests can mount / remove it. 15 | */ 16 | function Abortable(this: HTMLElement, props: JSX.Props<{ label?: string }>) { 17 | const signal = createAbortSignal(this); 18 | const labelAccessor = () => props().label ?? "abortable"; 19 | 20 | signal.addEventListener("abort", () => { 21 | // Immutable append for clarity. 22 | events = [...events, `${labelAccessor()}:aborted`]; 23 | }); 24 | 25 | return
{labelAccessor()}
; 26 | } 27 | 28 | /** Shared events list for assertions; reset before each test. */ 29 | let events: string[] = []; 30 | 31 | test.before.each(() => { 32 | events = []; 33 | }); 34 | 35 | /** 36 | * abort on unmount 37 | * Mount component, unmount via root API, expect exactly one abort event. 38 | */ 39 | test("abort on unmount", async () => { 40 | const rootApi = createRoot(document.body); 41 | const cmp = ; 42 | const connected = new Promise((resolve) => { 43 | (cmp as EventTarget).addEventListener( 44 | "connect", 45 | (e) => resolve(e.target as HTMLElement), 46 | { once: true }, 47 | ); 48 | }); 49 | rootApi.render(cmp); 50 | await connected; 51 | assert.length(events, 0); 52 | 53 | // Unmount root -> should trigger abort. 54 | rootApi.unmount(); 55 | 56 | // Allow microtasks to flush. 57 | await Promise.resolve(); 58 | 59 | assert.length(events, 1); 60 | assert.equal(events[0], "abortable:aborted"); 61 | }); 62 | 63 | /** 64 | * single abort event 65 | * Removing an already removed element should not produce a second abort. 66 | */ 67 | test("single abort event", async () => { 68 | const rootApi = createRoot(document.body); 69 | const cmp = ; 70 | const connected = new Promise((resolve) => { 71 | (cmp as EventTarget).addEventListener( 72 | "connect", 73 | (e) => resolve(e.target as HTMLElement), 74 | { once: true }, 75 | ); 76 | }); 77 | rootApi.render(cmp); 78 | await connected; 79 | assert.length(events, 0); 80 | 81 | // First unmount. 82 | rootApi.unmount(); 83 | await Promise.resolve(); 84 | assert.length(events, 1); 85 | assert.equal(events[0], "multi:aborted"); 86 | 87 | // Second unmount (noop / idempotent). 88 | rootApi.unmount(); 89 | await Promise.resolve(); 90 | assert.length(events, 1); 91 | }); 92 | 93 | await test.run(); 94 | -------------------------------------------------------------------------------- /src/tests/connect-events.test.tsx: -------------------------------------------------------------------------------- 1 | import { assert, test } from "@marcisbee/rion/test"; 2 | import { createRoot, update } from "../client.ts"; 3 | 4 | function Probe() { 5 | return
probe
; 6 | } 7 | 8 | test("connect in component", async () => { 9 | const probe = ; 10 | 11 | let called = 0; 12 | probe.addEventListener("connect", () => { 13 | called++; 14 | }); 15 | 16 | createRoot(document.body).render(probe); 17 | 18 | // Await microtask flush (Promise.then chain). 19 | await Promise.resolve(); 20 | 21 | assert.equal(called, 1); 22 | }); 23 | 24 | test("connect inside component", async () => { 25 | let called = 0; 26 | function Probe1(this: HTMLSelectElement) { 27 | this.addEventListener("connect", () => { 28 | called++; 29 | }); 30 | return
probe
; 31 | } 32 | const probe = ; 33 | 34 | createRoot(document.body).render(probe); 35 | 36 | // Await microtask flush (Promise.then chain). 37 | await Promise.resolve(); 38 | 39 | assert.equal(called, 1); 40 | }); 41 | 42 | test("connect in element", async () => { 43 | const probe =
; 44 | 45 | let called = 0; 46 | probe.addEventListener("connect", () => { 47 | called++; 48 | }); 49 | 50 | createRoot(document.body).render(probe); 51 | 52 | // Await microtask flush (Promise.then chain). 53 | await Promise.resolve(); 54 | 55 | assert.equal(called, 1); 56 | }); 57 | 58 | test("connect component switch", () => { 59 | let countA = 0; 60 | function ComponentA(this: HTMLElement) { 61 | this.addEventListener("connect", () => { 62 | countA++; 63 | }); 64 | return
A
; 65 | } 66 | 67 | let countB = 0; 68 | function ComponentB(this: HTMLElement) { 69 | this.addEventListener("connect", () => { 70 | countB++; 71 | }); 72 | return
B
; 73 | } 74 | 75 | let showA = true; 76 | function Switcher() { 77 | return () => (showA ? : ); 78 | } 79 | 80 | const root = createRoot(document.body).render(); 81 | 82 | assert.equal(countA, 1); 83 | assert.equal(countB, 0); 84 | 85 | showA = false; 86 | update(root); 87 | 88 | assert.equal(countA, 1); 89 | assert.equal(countB, 1); 90 | 91 | showA = true; 92 | update(root); 93 | 94 | assert.equal(countA, 2); 95 | assert.equal(countB, 1); 96 | }); 97 | 98 | test("disconnect component switch", () => { 99 | let countA = 0; 100 | function ComponentA(this: HTMLElement) { 101 | this.addEventListener("disconnect", () => { 102 | countA++; 103 | }); 104 | return
A
; 105 | } 106 | 107 | let countB = 0; 108 | function ComponentB(this: HTMLElement) { 109 | this.addEventListener("disconnect", () => { 110 | countB++; 111 | }); 112 | return
B
; 113 | } 114 | 115 | let showA = true; 116 | function Switcher() { 117 | return () => (showA ? : ); 118 | } 119 | 120 | const root = createRoot(document.body).render(); 121 | 122 | assert.equal(countA, 0); 123 | assert.equal(countB, 0); 124 | 125 | showA = false; 126 | update(root); 127 | 128 | assert.equal(countA, 1); 129 | assert.equal(countB, 0); 130 | 131 | showA = true; 132 | update(root); 133 | 134 | assert.equal(countA, 1); 135 | assert.equal(countB, 1); 136 | }); 137 | 138 | await test.run(); 139 | -------------------------------------------------------------------------------- /src/tests/root-update.test.tsx: -------------------------------------------------------------------------------- 1 | import { assert, test } from "@marcisbee/rion/test"; 2 | import { createRoot, update } from "../client.ts"; 3 | 4 | test.before.each(() => { 5 | document.body.innerHTML = ""; 6 | }); 7 | 8 | /** 9 | * Component whose displayed count increments on each update event. 10 | */ 11 | function Counting(this: HTMLElement) { 12 | let count = 0; 13 | this.addEventListener("update", () => { 14 | count++; 15 | }); 16 | return () =>
{() => count}
; 17 | } 18 | 19 | /** 20 | * Component with nested reactive child (function child) plus outer counter. 21 | * Outer counter increments on host update; inner span content is regenerated by nested reactive render. 22 | */ 23 | function ReactiveParent(this: HTMLElement) { 24 | let outer = 0; 25 | this.addEventListener("update", () => { 26 | outer++; 27 | }); 28 | 29 | return () => ( 30 |
31 | {() => outer} 32 | {() => ( 33 | {Math.random().toString().slice(0, 8)} 34 | )} 35 |
36 | ); 37 | } 38 | 39 | /** 40 | * update-increments 41 | * root.update() dispatches update events, causing reactive host to re-render. 42 | */ 43 | test("update-increments", () => { 44 | const root = createRoot(document.body); 45 | root.render(); 46 | 47 | const getText = () => 48 | (document.querySelector(".count") as HTMLDivElement).textContent; 49 | 50 | assert.equal(getText(), "0"); 51 | update(root.root!); 52 | assert.equal(getText(), "1"); 53 | update(root.root!); 54 | assert.equal(getText(), "2"); 55 | }); 56 | 57 | /** 58 | * update-no-remount 59 | * root.update() does not trigger additional connect/disconnect events (no remount). 60 | */ 61 | test("update-no-remount", () => { 62 | const instance = ; 63 | let connects = 0; 64 | let disconnects = 0; 65 | instance.addEventListener("connect", () => connects++); 66 | instance.addEventListener("disconnect", () => disconnects++); 67 | 68 | const root = createRoot(document.body); 69 | root.render(instance); 70 | assert.equal(connects, 1); 71 | assert.equal(disconnects, 0); 72 | 73 | update(root.root!); 74 | update(root.root!); 75 | update(root.root!); 76 | assert.equal(connects, 1); 77 | assert.equal(disconnects, 0); 78 | }); 79 | 80 | /** 81 | * update-nested-reactive 82 | * root.update() propagates update to nested reactive region (function child) & outer host. 83 | */ 84 | test("update-nested-reactive", () => { 85 | const root = createRoot(document.body); 86 | root.render(); 87 | 88 | const outerEl = () => 89 | (document.querySelector(".outer") as HTMLSpanElement).textContent; 90 | const innerEl = () => 91 | (document.querySelector(".inner") as HTMLSpanElement).textContent; 92 | 93 | const firstOuter = outerEl(); 94 | const firstInner = innerEl(); 95 | assert.equal(firstOuter, "0"); 96 | assert.true(firstInner.length > 0); 97 | 98 | update(root.root!); 99 | const secondOuter = outerEl(); 100 | const secondInner = innerEl(); 101 | assert.equal(secondOuter, "1"); 102 | assert.notEqual(secondInner, firstInner); 103 | 104 | update(root.root!); 105 | const thirdOuter = outerEl(); 106 | const thirdInner = innerEl(); 107 | assert.equal(thirdOuter, "2"); 108 | assert.notEqual(thirdInner, secondInner); 109 | }); 110 | 111 | await test.run(); 112 | -------------------------------------------------------------------------------- /src/server/jsx-runtime.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Server JSX Runtime for Radi. 3 | * 4 | * This runtime wires the JSX transform to the universal server renderer 5 | * (string output). Configure your compiler/bundler to use: 6 | * 7 | * jsxImportSource: 'radi' 8 | * moduleName: 'radi/server-jsx-runtime' 9 | * 10 | * when building for SSR so that maps to the server-side 11 | * createElement exported here instead of the DOM-oriented client version. 12 | * 13 | * Differences from the client runtime: 14 | * - Reactive/subscribable values are only evaluated/sampled once. 15 | * - No DOM mutation or lifecycle events (connect/disconnect/update). 16 | * - Output intended for `renderToString()` consumption. 17 | * 18 | * You can pair this with `radi/server` exports: 19 | * import { renderToString } from 'radi/server'; 20 | * const html = renderToString(); 21 | */ 22 | 23 | import { 24 | createElement as ssrCreateElement, 25 | Fragment as SSRFragment, 26 | } from "../server.ts"; 27 | import type { ComponentType } from "../renderer.ts"; 28 | 29 | type ServerChild = unknown; 30 | 31 | interface SourceInfo { 32 | fileName?: string; 33 | lineNumber?: number; 34 | columnNumber?: number; 35 | } 36 | 37 | type Props = Record | null; 38 | 39 | function prepare(type: unknown, props: Props, key?: unknown): unknown { 40 | const p: Record = props ? { ...props } : {}; 41 | if (key != null) { 42 | p.key = key; 43 | } 44 | const children = (p as any).children; // JSX transform guarantees presence or omission 45 | delete (p as { children?: unknown }).children; 46 | 47 | if (children === undefined) { 48 | return ssrCreateElement(type as string | ComponentType, p); 49 | } 50 | if (Array.isArray(children)) { 51 | return ssrCreateElement(type as string | ComponentType, p, ...children); 52 | } 53 | return ssrCreateElement(type as string | ComponentType, p, children); 54 | } 55 | 56 | /** 57 | * jsx: Single child form (JSX factory). 58 | */ 59 | export function jsx( 60 | type: unknown, 61 | props: Record, 62 | key?: ServerChild, 63 | ): unknown { 64 | return prepare(type, props, key); 65 | } 66 | 67 | /** 68 | * jsxs: Multiple children form (JSX factory). 69 | */ 70 | export function jsxs( 71 | type: unknown, 72 | props: Record, 73 | key?: ServerChild, 74 | ): unknown { 75 | return prepare(type, props, key); 76 | } 77 | 78 | /** 79 | * Development JSX factory variant (mirrors React/Preact signature). 80 | * Included for completeness; server dev tooling may choose to use a 81 | * dedicated dev runtime file with richer diagnostics if needed. 82 | */ 83 | export function jsxDEV( 84 | type: unknown, 85 | props: Record, 86 | key: ServerChild | undefined, 87 | _isStaticChildren: boolean, 88 | source?: SourceInfo, 89 | self?: unknown, 90 | ): unknown { 91 | const p: Record = props ? { ...props } : {}; 92 | if (key != null) p.key = key; 93 | if (source) { 94 | Object.defineProperty(p, "__source", { value: source, enumerable: false }); 95 | } 96 | if (self) { 97 | Object.defineProperty(p, "__self", { value: self, enumerable: false }); 98 | } 99 | return prepare(type, p, key); 100 | } 101 | 102 | /** 103 | * Fragment export required by the JSX transform. 104 | */ 105 | export const Fragment = SSRFragment; 106 | 107 | // Optional default export for certain bundlers. 108 | export default { jsx, jsxs, jsxDEV, Fragment }; 109 | -------------------------------------------------------------------------------- /src/tests/drummer.test.tsx: -------------------------------------------------------------------------------- 1 | import { assert, test } from "@marcisbee/rion/test"; 2 | import { mount } from "../../test/utils.ts"; 3 | import { createAbortSignal, update } from "../client.ts"; 4 | 5 | /** 6 | * Drummer component. 7 | * 8 | * Maintains immutable state object holding BPM value. 9 | * External custom events "bpm:increment" and "bpm:decrement" mutate BPM 10 | * via immutable replacement and trigger a re-render. 11 | * 12 | * An AbortSignal scoped to the component lifecycle automatically 13 | * unregisters event listeners when the component is removed from the DOM. 14 | * 15 | * Rendered text includes a stable random value captured on first mount to 16 | * verify that updates do not regenerate non-reactive values. 17 | * 18 | * @param this Host HTMLElement. 19 | */ 20 | function Drummer(this: HTMLElement) { 21 | let state = { bpm: 100 }; 22 | const abortSignal = createAbortSignal(this); 23 | const randomSeed = Math.random(); 24 | 25 | const increment = () => { 26 | state = { bpm: state.bpm + 1 }; 27 | update(this); 28 | }; 29 | 30 | const decrement = () => { 31 | state = { bpm: state.bpm - 1 }; 32 | update(this); 33 | }; 34 | 35 | this.addEventListener( 36 | "bpm:increment", 37 | () => { 38 | increment(); 39 | }, 40 | { signal: abortSignal }, 41 | ); 42 | 43 | this.addEventListener( 44 | "bpm:decrement", 45 | () => { 46 | decrement(); 47 | }, 48 | { signal: abortSignal }, 49 | ); 50 | 51 | return () => ( 52 |
53 | BPM: {state.bpm} Random: {randomSeed} 54 |
55 | ); 56 | } 57 | 58 | /** 59 | * events update bpm 60 | * Verifies increment/decrement custom events update BPM immutably 61 | * while keeping the random seed stable across renders. 62 | */ 63 | test("events update bpm", async () => { 64 | const root = await mount(, document.body); 65 | const div = root.querySelector(".drummer")!; 66 | const initialText = div.textContent!; 67 | const match = /Random:\s*(\d\.\d+)/.exec(initialText); 68 | assert.exists(match); 69 | const seed = match![1]; 70 | 71 | root.dispatchEvent(new CustomEvent("bpm:increment", { bubbles: true })); 72 | await Promise.resolve(); 73 | assert.true(div.textContent!.includes("BPM: 101")); 74 | assert.true(div.textContent!.includes(seed)); 75 | 76 | root.dispatchEvent(new CustomEvent("bpm:decrement", { bubbles: true })); 77 | root.dispatchEvent(new CustomEvent("bpm:decrement", { bubbles: true })); 78 | await Promise.resolve(); 79 | assert.true(div.textContent!.includes("BPM: 99")); 80 | assert.true(div.textContent!.includes(seed)); 81 | }); 82 | 83 | /** 84 | * abort stops events 85 | * After removing the component, listeners bound with the abort signal 86 | * should be detached; further dispatched events must not change BPM. 87 | */ 88 | test("abort stops events", async () => { 89 | const root = await mount(, document.body); 90 | const div = root.querySelector(".drummer")!; 91 | assert.true(div.textContent!.includes("BPM: 100")); 92 | 93 | // Remove component -> abort listeners. 94 | root.remove(); 95 | await Promise.resolve(); 96 | 97 | // Dispatch events after removal (listeners should be gone). 98 | root.dispatchEvent(new CustomEvent("bpm:increment")); 99 | root.dispatchEvent(new CustomEvent("bpm:decrement")); 100 | await Promise.resolve(); 101 | 102 | // BPM should remain unchanged. 103 | assert.true(div.textContent!.includes("BPM: 100")); 104 | }); 105 | 106 | await test.run(); 107 | -------------------------------------------------------------------------------- /src/tests/create-element.test.tsx: -------------------------------------------------------------------------------- 1 | import { assert, clock, test } from "@marcisbee/rion/test"; 2 | import { createElement, createRoot } from "../client.ts"; 3 | import { mount } from "../../test/utils.ts"; 4 | 5 | /* ----------------------------------------------------------------------------- 6 | Tests for createElement covering: 7 | - primitive children (string, number, boolean, null, undefined) 8 | - array/nested children flattening 9 | - component children (function component) 10 | - subscribable children (object with subscribe emitting multiple values) 11 | ----------------------------------------------------------------------------- */ 12 | 13 | test("primitives", () => { 14 | const host = document.createElement("div"); 15 | document.body.appendChild(host); 16 | const { render } = createRoot(host); 17 | 18 | const node = createElement( 19 | "div", 20 | null, 21 | "hello", 22 | 42, 23 | false, 24 | null, 25 | undefined, 26 | ); 27 | 28 | render(node as any); 29 | 30 | const div = host.querySelector("div")!; 31 | assert.exists(div); 32 | assert.contains(div.textContent, "hello42"); 33 | 34 | // Count comment nodes (false, null, undefined => 3 comments) 35 | let commentCount = 0; 36 | for (const child of Array.from(div.childNodes)) { 37 | if (child.nodeType === Node.COMMENT_NODE) commentCount++; 38 | } 39 | assert.equal(commentCount, 3); 40 | }); 41 | 42 | test("array-flatten", () => { 43 | const container = document.createElement("div"); 44 | document.body.appendChild(container); 45 | const { render } = createRoot(container); 46 | 47 | const nested =
{["a", ["b", ["c"]], null]}
; 48 | render(nested); 49 | 50 | const div = container.querySelector("div")!; 51 | assert.exists(div); 52 | assert.contains(div.textContent, "abc"); 53 | 54 | // Expect at least one comment for null 55 | const comments = Array.from(div.childNodes).filter((n) => 56 | n.nodeType === Node.COMMENT_NODE && 57 | /null/.test((n as Comment).textContent || "") 58 | ); 59 | assert.exists(comments.length >= 1); 60 | }); 61 | 62 | function ExampleComponent( 63 | this: DocumentFragment, 64 | propsGetter: () => { value: string; children?: any }, 65 | ) { 66 | const props = propsGetter(); 67 | return ( 68 |
69 | {props.value} 70 | {props.children} 71 |
72 | ); 73 | } 74 | 75 | test("component", async () => { 76 | const mounted = await mount( 77 | child, 78 | document.body, 79 | ); 80 | 81 | const div = mounted.querySelector(".ex")!; 82 | assert.exists(div); 83 | assert.equal(div.textContent, "Xchild"); 84 | assert.equal(mounted.tagName, "HOST"); 85 | }); 86 | 87 | test("subscribable", async () => { 88 | const host = document.createElement("div"); 89 | document.body.appendChild(host); 90 | const { render } = createRoot(host); 91 | 92 | // Simple subscribable that emits twice 93 | const store = { 94 | subscribe(fn: (v: string) => void) { 95 | fn("first"); 96 | setTimeout(() => fn("second"), 10); 97 | }, 98 | }; 99 | 100 | render(
{store}
); 101 | await Promise.resolve(); // Wait for initial render 102 | 103 | const div = host.querySelector("div")!; 104 | assert.exists(div); 105 | // After initial synchronous emission 106 | assert.equal(div.textContent, "first"); 107 | 108 | // Wait for second emission microtask 109 | await clock.fastForward(10); 110 | assert.equal(div.textContent, "second"); 111 | }); 112 | 113 | await test.run(); 114 | -------------------------------------------------------------------------------- /src/tests/memo-props.test.tsx: -------------------------------------------------------------------------------- 1 | import { assert, test } from "@marcisbee/rion/test"; 2 | import { mount } from "../../test/utils.ts"; 3 | import { memo, update } from "../client.ts"; 4 | 5 | function counterMemo(skip: () => boolean) { 6 | let i = 0; 7 | return memo(() => i++, skip); 8 | } 9 | 10 | test("memo prop primitive - always re-renders when skip=false", async () => { 11 | const count = counterMemo(() => false); 12 | 13 | function App() { 14 | return
; 15 | } 16 | 17 | const container = await mount(, document.body); 18 | const el = container.querySelector("div")!; 19 | 20 | assert.equal(el.getAttribute("data-count"), "0"); 21 | 22 | update(container); 23 | assert.equal(el.getAttribute("data-count"), "1"); 24 | 25 | update(container); 26 | assert.equal(el.getAttribute("data-count"), "2"); 27 | }); 28 | 29 | test("memo prop primitive - skips when skip=true", async () => { 30 | const count = counterMemo(() => true); 31 | 32 | function App() { 33 | return
; 34 | } 35 | 36 | const container = await mount(, document.body); 37 | const el = container.querySelector("div")!; 38 | 39 | // Initial render always happens 40 | assert.equal(el.getAttribute("data-count"), "0"); 41 | 42 | update(container); 43 | assert.equal(el.getAttribute("data-count"), "0"); 44 | 45 | update(container); 46 | assert.equal(el.getAttribute("data-count"), "0"); 47 | }); 48 | 49 | test("memo prop primitive - mixed skip behavior", async () => { 50 | const a = counterMemo(() => false); // updates each cycle 51 | const b = counterMemo(() => true); // only initial 52 | 53 | function App() { 54 | return
; 55 | } 56 | 57 | const container = await mount(, document.body); 58 | const el = container.querySelector("div")!; 59 | 60 | assert.equal(el.getAttribute("data-a"), "0"); 61 | assert.equal(el.getAttribute("data-b"), "0"); 62 | 63 | update(container); 64 | assert.equal(el.getAttribute("data-a"), "1"); 65 | assert.equal(el.getAttribute("data-b"), "0"); 66 | 67 | update(container); 68 | assert.equal(el.getAttribute("data-a"), "2"); 69 | assert.equal(el.getAttribute("data-b"), "0"); 70 | }); 71 | 72 | test("memo prop primitive alongside memo child", async () => { 73 | let childI = 0; 74 | const propCount = counterMemo(() => false); 75 | const childCount = memo(() => childI++, () => false); 76 | 77 | function App() { 78 | return ( 79 |
80 | Child:{childCount} 81 |
82 | ); 83 | } 84 | 85 | const container = await mount(, document.body); 86 | const el = container.querySelector("div")!; 87 | 88 | assert.equal(el.getAttribute("data-prop"), "0"); 89 | assert.match(el.textContent || "", /Child:0/); 90 | 91 | update(container); 92 | assert.equal(el.getAttribute("data-prop"), "1"); 93 | assert.match(el.textContent || "", /Child:1/); 94 | 95 | update(container); 96 | assert.equal(el.getAttribute("data-prop"), "2"); 97 | assert.match(el.textContent || "", /Child:2/); 98 | }); 99 | 100 | test("memo prop skip mixed with non-memo reactive prop", async () => { 101 | let raw = 0; 102 | const memoCount = counterMemo(() => true); // stays at initial 0 103 | 104 | function App() { 105 | return
raw++} />; 106 | } 107 | 108 | const container = await mount(, document.body); 109 | const el = container.querySelector("div")!; 110 | 111 | assert.equal(el.getAttribute("data-memo"), "0"); 112 | assert.equal(el.getAttribute("data-raw"), "0"); 113 | 114 | update(container); 115 | assert.equal(el.getAttribute("data-memo"), "0"); 116 | assert.equal(el.getAttribute("data-raw"), "1"); 117 | 118 | update(container); 119 | assert.equal(el.getAttribute("data-memo"), "0"); 120 | assert.equal(el.getAttribute("data-raw"), "2"); 121 | }); 122 | 123 | await test.run(); 124 | -------------------------------------------------------------------------------- /src/bench/update.bench.tsx: -------------------------------------------------------------------------------- 1 | import { bench } from "@marcisbee/rion/bench"; 2 | import { locator } from "@marcisbee/rion/locator"; 3 | import { createElement as createElementReact, useState } from "npm:react"; 4 | import { createRoot as createRootReact } from "npm:react-dom/client"; 5 | import { flushSync } from "npm:react-dom"; 6 | 7 | import { createRoot, update } from "../client.ts"; 8 | 9 | let count = 0; 10 | 11 | bench("innerHTML", { 12 | setup() { 13 | count = 0; 14 | function update() { 15 | document.body.innerHTML = ``; 16 | const button = document.body.querySelector("button") as HTMLButtonElement; 17 | button.onclick = () => { 18 | count++; 19 | update(); 20 | }; 21 | } 22 | update(); 23 | }, 24 | }, async () => { 25 | const countToWaitFor = count + 1; 26 | const button = await locator("button").getOne() as HTMLButtonElement; 27 | 28 | button.click(); 29 | 30 | await locator("button").hasText(String(countToWaitFor)).getOne(); 31 | }); 32 | 33 | bench("textContent", { 34 | async setup() { 35 | count = 0; 36 | document.body.innerHTML = ``; 37 | const button = await locator("button") 38 | .getOne() as HTMLButtonElement; 39 | const buttonText = button.childNodes[0] as Text; 40 | button.onclick = () => { 41 | count++; 42 | buttonText.textContent = String(count); 43 | }; 44 | }, 45 | }, async () => { 46 | const countToWaitFor = count + 1; 47 | const button = await locator("button").getOne() as HTMLButtonElement; 48 | 49 | button.click(); 50 | 51 | await locator("button").hasText(String(countToWaitFor)).getOne(); 52 | }); 53 | 54 | bench("nodeValue", { 55 | async setup() { 56 | count = 0; 57 | document.body.innerHTML = ``; 58 | const button = await locator("button").getOne() as HTMLButtonElement; 59 | const buttonText = button.childNodes[0] as Text; 60 | button.onclick = () => { 61 | count++; 62 | buttonText.nodeValue = String(count); 63 | }; 64 | }, 65 | }, async () => { 66 | const countToWaitFor = count + 1; 67 | const button = await locator("button").getOne() as HTMLButtonElement; 68 | 69 | button.click(); 70 | 71 | await locator("button").hasText(String(countToWaitFor)).getOne(); 72 | }); 73 | 74 | bench("radi", { 75 | async setup() { 76 | function RadiCounter(this: HTMLElement) { 77 | return ( 78 | 86 | ); 87 | } 88 | 89 | count = 0; 90 | const cmp = ; 91 | createRoot(document.body).render(cmp); 92 | 93 | await locator("button").hasText(String(0)).getOne(); 94 | }, 95 | }, async () => { 96 | const countToWaitFor = count + 1; 97 | const button = await locator("button").getOne() as HTMLButtonElement; 98 | 99 | button.click(); 100 | 101 | await locator("button").hasText(String(countToWaitFor)).getOne(); 102 | }); 103 | 104 | bench("react", { 105 | async setup() { 106 | function ReactCounter() { 107 | const [, setTick] = useState(0); 108 | 109 | return createElementReact( 110 | "button", 111 | { 112 | onClick: () => { 113 | count++; 114 | flushSync(() => setTick((t) => t + 1)); 115 | }, 116 | }, 117 | String(count), 118 | ); 119 | } 120 | 121 | count = 0; 122 | createRootReact(document.body).render( 123 | createElementReact(ReactCounter, null), 124 | ); 125 | 126 | await locator("button").hasText(String(0)).getOne(); 127 | }, 128 | }, async () => { 129 | const countToWaitFor = count + 1; 130 | const button = await locator("button").getOne() as HTMLButtonElement; 131 | 132 | button.click(); 133 | 134 | await locator("button").hasText(String(countToWaitFor)).getOne(); 135 | }); 136 | 137 | await bench.run(); 138 | -------------------------------------------------------------------------------- /src/tests/closure.test.tsx: -------------------------------------------------------------------------------- 1 | import { assert, test } from "@marcisbee/rion/test"; 2 | import { mount } from "../../test/utils.ts"; 3 | import { update } from "../client.ts"; 4 | 5 | let trace: string[] = []; 6 | 7 | test.before.each(() => { 8 | trace = []; 9 | }); 10 | 11 | function ListChild(this: HTMLElement) { 12 | let items = ["A", "B", "C"]; 13 | return ( 14 | 22 | ); 23 | } 24 | 25 | function ListParent(this: HTMLElement) { 26 | let items = ["A", "B", "C"]; 27 | return ( 28 | 36 | ); 37 | } 38 | 39 | function ListBoth(this: HTMLElement) { 40 | let items = ["A", "B", "C"]; 41 | return ( 42 | 51 | ); 52 | } 53 | 54 | test("render parent", async () => { 55 | const container = await mount(, document.body); 56 | 57 | assert.snapshot.html( 58 | container, 59 | ` 60 | 66 | `, 67 | ); 68 | }); 69 | 70 | test("render child", async () => { 71 | const container = await mount(, document.body); 72 | 73 | assert.snapshot.html( 74 | container, 75 | ` 76 | 81 | `, 82 | ); 83 | }); 84 | 85 | test("render both", async () => { 86 | const container = await mount(, document.body); 87 | 88 | assert.snapshot.html( 89 | container, 90 | ` 91 | 97 | `, 98 | ); 99 | }); 100 | 101 | test("mutate parent", async () => { 102 | const container = await mount(, document.body); 103 | 104 | container.querySelector("button")!.click(); 105 | 106 | assert.snapshot.html( 107 | container, 108 | ` 109 | 115 | `, 116 | ); 117 | 118 | await Promise.resolve(); 119 | assert.deepEqual(trace, ["A", "B", "C", "C", "B", "A"]); 120 | }); 121 | 122 | test("mutate child", async () => { 123 | const container = await mount(, document.body); 124 | 125 | container.querySelector("button")!.click(); 126 | 127 | assert.snapshot.html( 128 | container, 129 | ` 130 | 135 | `, 136 | ); 137 | 138 | await Promise.resolve(); 139 | assert.deepEqual(trace, ["A", "B", "C", "A", "B", "C"]); 140 | }); 141 | 142 | test("mutate both", async () => { 143 | const container = await mount(, document.body); 144 | 145 | container.querySelector("button")!.click(); 146 | 147 | assert.snapshot.html( 148 | container, 149 | ` 150 | 156 | `, 157 | ); 158 | 159 | await Promise.resolve(); 160 | assert.deepEqual(trace, ["A", "B", "C", "C", "B", "A"]); 161 | }); 162 | 163 | await test.run(); 164 | -------------------------------------------------------------------------------- /src/tests/counter.test.tsx: -------------------------------------------------------------------------------- 1 | import { assert, test } from "@marcisbee/rion/test"; 2 | import { mount } from "../../test/utils.ts"; 3 | import { update } from "../client.ts"; 4 | 5 | /** 6 | * Counter component and related tests. 7 | * 8 | * The component maintains an internal immutable state object: 9 | * state = { count: number } 10 | * Each increment replaces the state object (immutability) and triggers an update. 11 | * 12 | * Tests: 13 | * - render: initial DOM structure snapshot. 14 | * - increment: clicking the increment button updates visible count. 15 | * - manual-update-no-change: forcing update without state modification keeps value. 16 | * - instances-isolated: multiple counters do not share state. 17 | * - no-duplicate-nodes: re-renders do not duplicate existing DOM nodes. 18 | */ 19 | function Counter(this: HTMLElement) { 20 | let count = 0; 21 | 22 | return ( 23 |
24 | {() => String(count)} 25 | 34 |
35 | ); 36 | } 37 | 38 | test("render", async () => { 39 | const container = await mount(, document.body); 40 | 41 | assert.snapshot.html( 42 | container, 43 | ` 44 |
45 | 0 46 | 47 |
48 |
`, 49 | ); 50 | }); 51 | 52 | test("increment", async () => { 53 | const container = await mount(, document.body); 54 | const spanBefore = container.querySelector("span")!; 55 | assert.equal(spanBefore.textContent, "0"); 56 | 57 | const button = container.querySelector(".btn-inc") as HTMLButtonElement; 58 | button.click(); 59 | button.click(); 60 | button.click(); 61 | 62 | const spanAfter = container.querySelector("span")!; 63 | assert.equal(spanAfter.textContent, "3"); 64 | }); 65 | 66 | test("manual-update-no-change", async () => { 67 | const container = await mount(, document.body); 68 | const span = container.querySelector("span")!; 69 | assert.equal(span.textContent, "0"); 70 | 71 | // Update without modifying state object reference. 72 | update(container); 73 | assert.equal(span.textContent, "0"); 74 | }); 75 | 76 | test("instances-isolated", async () => { 77 | const container = await mount( 78 |
79 | 80 | 81 |
, 82 | document.body, 83 | ); 84 | 85 | const counters = container.querySelectorAll("host"); 86 | assert.equal(counters.length, 2); 87 | 88 | const spans = container.querySelectorAll("span"); 89 | assert.equal(spans.length, 2); 90 | 91 | const buttons = container.querySelectorAll(".btn-inc"); 92 | assert.equal(buttons.length, 2); 93 | 94 | (buttons[0] as HTMLButtonElement).click(); 95 | (buttons[0] as HTMLButtonElement).click(); 96 | (buttons[1] as HTMLButtonElement).click(); 97 | 98 | assert.equal(spans[0].textContent, "2"); 99 | assert.equal(spans[1].textContent, "1"); 100 | }); 101 | 102 | test("no-duplicate-nodes", async () => { 103 | const container = await mount(, document.body); 104 | 105 | assert.snapshot.html( 106 | container, 107 | ` 108 |
109 | 0 110 | 111 |
112 |
`, 113 | ); 114 | 115 | const button = container.querySelector(".btn-inc") as HTMLButtonElement; 116 | const span = container.querySelector("span")!; 117 | button.click(); 118 | button.click(); 119 | 120 | assert.equal(span.textContent, "2"); 121 | assert.equal(container.querySelectorAll("span").length, 1); 122 | assert.equal(container.querySelectorAll("button").length, 1); 123 | 124 | assert.snapshot.html( 125 | container, 126 | ` 127 |
128 | 2 129 | 130 |
131 |
`, 132 | ); 133 | }); 134 | 135 | await test.run(); 136 | -------------------------------------------------------------------------------- /src/tests/root-reconcile.test.tsx: -------------------------------------------------------------------------------- 1 | import { assert, test } from "@marcisbee/rion/test"; 2 | import { createKey, createRoot } from "../client.ts"; 3 | 4 | /** 5 | * Simple component whose displayed value depends on its props. 6 | * Uses a reactive render function so update events reconcile content. 7 | */ 8 | function App( 9 | this: HTMLElement, 10 | props: JSX.Props<{ counter: number; key?: string }>, 11 | ) { 12 | return () =>
{() => props().counter}
; 13 | } 14 | 15 | function AppA(this: HTMLElement) { 16 | return () =>
A
; 17 | } 18 | 19 | function AppB(this: HTMLElement) { 20 | return () =>
B
; 21 | } 22 | 23 | test.before.each(() => { 24 | document.body.innerHTML = ""; 25 | }); 26 | 27 | /** 28 | * reuse-component-props 29 | * Multiple root.render() calls with same component type update props without remount. 30 | */ 31 | test("reuse-component-props", () => { 32 | const root = createRoot(document.body); 33 | let connectCount = 0; 34 | let disconnectCount = 0; 35 | 36 | // Attach listeners directly on first instance (events are non-bubbling). 37 | const first = ; 38 | first.addEventListener("connect", () => connectCount++); 39 | first.addEventListener("disconnect", () => disconnectCount++); 40 | root.render(first); 41 | 42 | const valueEl0 = document.querySelector(".value") as HTMLDivElement | null; 43 | if (!valueEl0) throw new Error("value element missing"); 44 | assert.equal(valueEl0.textContent, "0"); 45 | 46 | // Subsequent renders reuse component host (no new connect). 47 | for (let i = 1; i < 3; i++) { 48 | root.render(); 49 | const valueEl = document.querySelector(".value") as HTMLDivElement | null; 50 | if (!valueEl) throw new Error("value element missing"); 51 | assert.equal(valueEl.textContent, String(i)); 52 | } 53 | 54 | assert.equal(connectCount, 1); 55 | assert.equal(disconnectCount, 0); 56 | }); 57 | 58 | /** 59 | * replace-component-type 60 | * Rendering different component types causes old host to disconnect and new to connect. 61 | */ 62 | test("replace-component-type", () => { 63 | const root = createRoot(document.body); 64 | let connectA = 0; 65 | let disconnectA = 0; 66 | let connectB = 0; 67 | let disconnectB = 0; 68 | 69 | const a = ; 70 | a.addEventListener("connect", () => connectA++); 71 | a.addEventListener("disconnect", () => disconnectA++); 72 | root.render(a); 73 | assert.equal( 74 | (document.querySelector(".which") as HTMLElement).textContent, 75 | "A", 76 | ); 77 | 78 | const b = ; 79 | b.addEventListener("connect", () => connectB++); 80 | b.addEventListener("disconnect", () => disconnectB++); 81 | root.render(b); 82 | assert.equal( 83 | (document.querySelector(".which") as HTMLElement).textContent, 84 | "B", 85 | ); 86 | 87 | assert.equal(connectA, 1); 88 | assert.equal(disconnectA, 1); 89 | assert.equal(connectB, 1); 90 | assert.equal(disconnectB, 0); 91 | }); 92 | 93 | /** 94 | * replace-component-key 95 | * Changing key forces remount even with same component type. 96 | */ 97 | test("replace-component-key", () => { 98 | const root = createRoot(document.body); 99 | let connects = 0; 100 | let disconnects = 0; 101 | 102 | function Wrapper1() { 103 | return () => createKey(() => , "one"); 104 | } 105 | 106 | const one = ; 107 | one.addEventListener("connect", () => connects++); 108 | one.addEventListener("disconnect", () => disconnects++); 109 | root.render(one); 110 | assert.equal( 111 | (document.querySelector(".value") as HTMLElement).textContent, 112 | "0", 113 | ); 114 | 115 | function Wrapper2() { 116 | return () => createKey(() => , "two"); 117 | } 118 | 119 | const two = ; 120 | two.addEventListener("connect", () => connects++); 121 | two.addEventListener("disconnect", () => disconnects++); 122 | root.render(two); 123 | assert.equal( 124 | (document.querySelector(".value") as HTMLElement).textContent, 125 | "1", 126 | ); 127 | 128 | assert.equal(connects, 2); 129 | assert.equal(disconnects, 1); 130 | }); 131 | 132 | await test.run(); 133 | -------------------------------------------------------------------------------- /AGENTS.md: -------------------------------------------------------------------------------- 1 | A focused guide for coding agents. Follow these principles to produce small, 2 | fast, simple, and maintainable code. 3 | 4 | ## Core pillars 5 | 6 | - Code must be small 7 | - Write only what is necessary to meet the requirement. 8 | - Prefer fewer files, fewer branches, and fewer abstractions when possible. 9 | - Remove dead code, unused dependencies, and redundant helpers. 10 | 11 | - Code must be performant 12 | - Choose algorithms and data structures with appropriate complexity. 13 | - Avoid unnecessary allocations, deep copies, or extra passes over data. 14 | - Measure critical paths; optimize based on evidence, not assumptions. 15 | 16 | - KISS (Keep It Simple, Stupid) 17 | - Prefer straightforward solutions over clever ones. 18 | - Reduce moving parts; favor direct composition over indirection. 19 | - If a solution feels complex, explore a simpler design first. 20 | 21 | - Single Responsibility Principle (SRP) 22 | - Each unit (function, class, module) does one job well. 23 | - Split multi-purpose logic into focused units with clear boundaries. 24 | - Keep public interfaces minimal and coherent. 25 | 26 | - Don’t overuse comments 27 | - Write self-explanatory code with clear names and small functions. 28 | - Use comments for rationale or non-obvious decisions, not restating code. 29 | - Remove outdated or misleading comments. 30 | 31 | ## Design guidelines 32 | 33 | - Minimalism in code 34 | - Limit code to what is necessary. Avoid extra layers, flags, and parameters 35 | that do not serve the immediate requirement. 36 | - Prefer deletion and simplification over generalization. 37 | 38 | - Simplicity in structure 39 | - Use flat, consistent project layouts. Avoid deep hierarchies and 40 | cross-module dependencies unless required. 41 | - Keep dependency graphs simple; prevent circular or implicit coupling. 42 | 43 | - Modularity 44 | - Divide logic into small, independent modules that are easy to test and 45 | reuse. 46 | - Encapsulate implementation details; expose minimal, well-defined interfaces. 47 | 48 | - Clear naming 49 | - Use descriptive, plain-language names that convey purpose (what, not how). 50 | - Avoid abbreviations and terms of art unless standard and necessary. 51 | 52 | - Avoiding overengineering 53 | - Use design patterns only when they solve a concrete problem. 54 | - Prefer direct implementations; avoid speculative abstractions. 55 | - Watch for unnecessary factories, strategies, and layers (~ 56 | Over-engineeering). 57 | 58 | ## Testing 59 | 60 | - Goals 61 | - Tests must be fast, deterministic, and focused on behavior. 62 | - Cover critical paths and edge cases before micro-optimizations. 63 | 64 | - Run tests 65 | - Run all tests: `deno task test` 66 | - Run tests in specific file: `deno task test ""` 67 | 68 | - Guidance for agents 69 | - If no test framework is configured, scaffold minimal tests with Vitest or 70 | Jest. 71 | - Prefer unit tests that mirror the SRP of modules. 72 | - Keep test names explicit: “does X when Y” and avoid ambiguous terms. 73 | 74 | ## Project-specific rules 75 | 76 | - use kebab-case for file names 77 | - use camelCase for variable and function names 78 | - use PascalCase for class names and React components 79 | - use UPPER_SNAKE_CASE for constants 80 | - use 2 spaces for indentation 81 | - use single quotes for strings 82 | - use semicolons at the end of statements 83 | - use trailing commas in multi-line objects and arrays 84 | - use template literals for string interpolation 85 | - do not use default exports, use named exports instead 86 | - function content should be less than 50 lines 87 | - avoid using `any` type in TypeScript, use specific types instead 88 | - prefer `const` over `let` and `let` over `var` 89 | - use loops like `for...of` or array methods like `.map()`, `.filter()`, and 90 | `.reduce()` instead of traditional `for` loops 91 | - use destructuring assignment for objects and arrays 92 | - use css modules for styling React components 93 | - avoid using inline styles in React components, use CSS classes instead 94 | - prefer immutability, avoid mutating objects and arrays directly 95 | 96 | ## Agent notes 97 | 98 | - When uncertain, choose the simplest working solution and document trade-offs 99 | briefly. 100 | - Prefer deletion and refactoring over adding complexity. 101 | - Ask for missing requirements rather than guessing abstractions. 102 | - Avoid using terminal commands for file operations; use provided tools. 103 | -------------------------------------------------------------------------------- /src/tests/update-traversal.test.tsx: -------------------------------------------------------------------------------- 1 | import { assert, test } from "@marcisbee/rion/test"; 2 | import { mount } from "../../test/utils.ts"; 3 | import { createList, update } from "../client.ts"; 4 | 5 | /** 6 | * Child component with reactive render counter. 7 | */ 8 | function Child() { 9 | let renders = 0; 10 | return () =>
{++renders}
; 11 | } 12 | 13 | /** 14 | * Reactive parent (returns a function) hosting a child component. 15 | * Child should receive exactly one update per manual cycle (no duplication). 16 | */ 17 | function ParentReactive(this: HTMLElement) { 18 | return () => ( 19 |
20 | 21 |
22 | ); 23 | } 24 | 25 | /** 26 | * Non-reactive parent (returns plain DOM) hosting a child component. 27 | * Traversal must propagate update events to nested component host directly. 28 | */ 29 | function ParentNonReactive(this: HTMLElement) { 30 | return ( 31 |
32 | 33 |
34 | ); 35 | } 36 | 37 | /** 38 | * Keyed item component used to verify no double updates under a reactive parent list. 39 | */ 40 | function KeyedItem(props: JSX.Props<{ id: string }>) { 41 | let renders = 0; 42 | return () => {props().id}:{++renders}; 43 | } 44 | 45 | /** 46 | * Reactive list parent producing keyed child component hosts. 47 | * Manual update should increment each child's counter by exactly 1. 48 | */ 49 | function ReactiveList(this: HTMLElement) { 50 | const keys = ["a", "b", "c"]; 51 | return () => ( 52 |
53 | {() => 54 | createList((key) => 55 | keys.map((k) => key(() => , k)) 56 | )} 57 |
58 | ); 59 | } 60 | 61 | test("nested component under reactive parent single update per cycle", async () => { 62 | const root = await mount(, document.body); 63 | const childHost = root.querySelector("host") as HTMLElement; 64 | assert.true(!!childHost, "child host exists"); 65 | 66 | let childUpdates = 0; 67 | childHost.addEventListener("update", () => childUpdates++); 68 | 69 | const text = () => 70 | (childHost.querySelector(".child-count") as HTMLElement).textContent!; 71 | 72 | assert.equal(text(), "1"); 73 | update(root); 74 | await Promise.resolve(); 75 | assert.equal(childUpdates, 1); 76 | assert.equal(text(), "2"); 77 | 78 | update(root); 79 | await Promise.resolve(); 80 | assert.equal(childUpdates, 2); 81 | assert.equal(text(), "3"); 82 | }); 83 | 84 | test("nested component under non-reactive parent receives update via traversal", async () => { 85 | const root = await mount(, document.body); 86 | // ParentNonReactive returns plain div; child component host is first host inside root host. 87 | const childHost = root.querySelector("host") as HTMLElement; 88 | assert.true(!!childHost, "child host exists"); 89 | 90 | let received = 0; 91 | childHost.addEventListener("update", () => received++); 92 | 93 | const text = () => 94 | (childHost.querySelector(".child-count") as HTMLElement).textContent!; 95 | 96 | assert.equal(text(), "1", "initial child render"); 97 | update(root); 98 | await Promise.resolve(); 99 | assert.equal( 100 | received, 101 | 1, 102 | "child got update from non-reactive parent traversal", 103 | ); 104 | assert.equal(text(), "2", "child re-rendered exactly once"); 105 | 106 | update(root); 107 | await Promise.resolve(); 108 | assert.equal(received, 2, "child got second update"); 109 | assert.equal(text(), "3", "child re-rendered exactly once again"); 110 | }); 111 | 112 | test("reactive parent with keyed children increments each exactly once per manual update", async () => { 113 | const root = await mount(, document.body); 114 | 115 | const items = () => 116 | Array.from(root.querySelectorAll(".keyed-item")) as HTMLElement[]; 117 | 118 | // Initial renders should all be 1 119 | for (const el of items()) { 120 | const [id, count] = el.textContent!.split(":"); 121 | assert.equal(count, "1", "initial render count = 1 for " + id); 122 | } 123 | 124 | update(root); 125 | await Promise.resolve(); 126 | for (const el of items()) { 127 | const [id, count] = el.textContent!.split(":"); 128 | assert.equal(count, "2", "after first update count = 2 for " + id); 129 | } 130 | 131 | update(root); 132 | await Promise.resolve(); 133 | for (const el of items()) { 134 | const [id, count] = el.textContent!.split(":"); 135 | assert.equal(count, "3", "after second update count = 3 for " + id); 136 | } 137 | }); 138 | 139 | await test.run(); 140 | -------------------------------------------------------------------------------- /src/tests/mutation.test.tsx: -------------------------------------------------------------------------------- 1 | import { assert, test } from "@marcisbee/rion/test"; 2 | import { mount } from "../../test/utils.ts"; 3 | import { update } from "../client.ts"; 4 | 5 | /** 6 | * Module-level immutable state object used by the Counter component. 7 | * Tests mutate by replacing the object reference to simulate state changes. 8 | */ 9 | let state: { value: number } = { value: 0 }; 10 | 11 | /** 12 | * Counter 13 | * Renders two buttons: 14 | * - Mutate: increments internal state immutably without triggering re-render. 15 | * - Update: triggers a re-render reflecting the latest immutable state. 16 | * 17 | * Reactive span displays current state value. 18 | * @param this Host HTMLElement. 19 | */ 20 | function Counter(this: HTMLElement) { 21 | return ( 22 |
23 | {() => String(state.value)} 24 | 33 | 41 |
42 | ); 43 | } 44 | 45 | /** Reset state before each test for isolation. */ 46 | test.before.each(() => { 47 | state = { value: 0 }; 48 | }); 49 | 50 | /** render */ 51 | test("render", async () => { 52 | const container = await mount(, document.body); 53 | 54 | assert.snapshot.html( 55 | container, 56 | ` 57 |
58 | 0 59 | 60 | 61 |
62 |
`, 63 | ); 64 | }); 65 | 66 | /** mutate-no-update */ 67 | test("mutate-no-update", async () => { 68 | const container = await mount(, document.body); 69 | const span = container.querySelector("span")!; 70 | const [btnMutate] = container.querySelectorAll("button"); 71 | 72 | assert.equal(span.textContent, "0"); 73 | (btnMutate as HTMLButtonElement).click(); 74 | (btnMutate as HTMLButtonElement).click(); 75 | 76 | // No update triggered yet → value unchanged. 77 | assert.equal(span.textContent, "0"); 78 | }); 79 | 80 | /** mutate-then-button-update */ 81 | test("mutate-then-button-update", async () => { 82 | const container = await mount(, document.body); 83 | const span = container.querySelector("span")!; 84 | const [btnMutate, btnUpdate] = container.querySelectorAll("button"); 85 | 86 | assert.equal(span.textContent, "0"); 87 | (btnMutate as HTMLButtonElement).click(); 88 | (btnMutate as HTMLButtonElement).click(); 89 | (btnUpdate as HTMLButtonElement).click(); 90 | 91 | assert.equal(span.textContent, "2"); 92 | }); 93 | 94 | /** mutate-then-container-update */ 95 | test("mutate-then-container-update", async () => { 96 | const container = await mount(, document.body); 97 | const span = container.querySelector("span")!; 98 | const [btnMutate] = container.querySelectorAll("button"); 99 | 100 | assert.equal(span.textContent, "0"); 101 | (btnMutate as HTMLButtonElement).click(); 102 | (btnMutate as HTMLButtonElement).click(); 103 | 104 | update(container); 105 | 106 | assert.equal(span.textContent, "2"); 107 | }); 108 | 109 | /** manual-mutate-button-update */ 110 | test("manual-mutate-button-update", async () => { 111 | const container = await mount(, document.body); 112 | const span = container.querySelector("span")!; 113 | const [, btnUpdate] = container.querySelectorAll("button"); 114 | 115 | assert.equal(span.textContent, "0"); 116 | state = { value: 2 }; 117 | (btnUpdate as HTMLButtonElement).click(); 118 | 119 | assert.equal(span.textContent, "2"); 120 | }); 121 | 122 | /** manual-mutate-container-update */ 123 | test("manual-mutate-container-update", async () => { 124 | const container = await mount(, document.body); 125 | const span = container.querySelector("span")!; 126 | 127 | assert.equal(span.textContent, "0"); 128 | state = { value: 2 }; 129 | update(container); 130 | 131 | assert.equal(span.textContent, "2"); 132 | }); 133 | 134 | /** manual-mutate-span-update */ 135 | test("manual-mutate-span-update", async () => { 136 | const container = await mount(, document.body); 137 | const span = container.querySelector("span")!; 138 | 139 | assert.equal(span.textContent, "0"); 140 | state = { value: 2 }; 141 | update(span); 142 | 143 | assert.equal(span.textContent, "2"); 144 | }); 145 | 146 | await test.run(); 147 | -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Server entrypoint for Radi (SSR / HTML string rendering). 3 | * 4 | * This module exposes a universal, string-based renderer built from the 5 | * lightweight abstraction in `renderer.ts`. It is intentionally minimal: 6 | * - One-shot expansion of component + reactive function outputs. 7 | * - Subscribables are sampled once (initial value only if synchronous). 8 | * - No lifecycle events (connect / disconnect / update) are dispatched. 9 | * 10 | * Typical usage: 11 | * 12 | * import { 13 | * renderToString, 14 | * ssrCreateElement as createElement, 15 | * ssrFragment as Fragment 16 | * } from 'radi/server'; 17 | * 18 | * const html = renderToString( 19 | * ssrCreateElement('div', { class: 'app' }, 20 | * ssrCreateElement(App, null) 21 | * ) 22 | * ); 23 | * 24 | * Or with JSX (configure your JSX transform to point at radi/server for SSR): 25 | * 26 | * const html = renderToString(); 27 | * 28 | * For custom adapters (streaming, terminal, etc.), build your own: 29 | * 30 | * import { createRenderer, createServerStringAdapter } from 'radi/server'; 31 | * const adapter = createServerStringAdapter(); 32 | * const { renderToString: customRenderToString } = createRenderer(adapter); 33 | * 34 | * NOTE: The high-level DOM-centric APIs from `main.ts` are not imported here 35 | * because they depend on real browser `document` operations. 36 | */ 37 | 38 | import type { Child } from "./types.ts"; 39 | import { 40 | createDomAdapter, // exported for symmetry (may be useful in isomorphic setups) 41 | createRenderer, 42 | createServerStringAdapter, 43 | SERVER_RENDERER, 44 | } from "./renderer.ts"; 45 | 46 | /* -------------------------------------------------------------------------- */ 47 | /* Destructure preconfigured server renderer */ 48 | /* -------------------------------------------------------------------------- */ 49 | 50 | const { 51 | renderToString, 52 | createElement: ssrCreateElement, 53 | fragment: ssrFragment, 54 | createTextNode: ssrCreateTextNode, 55 | createComment: ssrCreateComment, 56 | } = SERVER_RENDERER; 57 | 58 | /* -------------------------------------------------------------------------- */ 59 | /* Convenience helpers */ 60 | /* -------------------------------------------------------------------------- */ 61 | 62 | /** 63 | * Render a single Child (component, element, fragment, primitives) to an HTML string. 64 | * This is an alias of the underlying renderer's renderToString for semantic clarity. 65 | */ 66 | export function renderToStringRoot(child: Child): string { 67 | if (!renderToString) { 68 | throw new Error("Server renderer missing renderToString implementation."); 69 | } 70 | return renderToString(child); 71 | } 72 | 73 | /** 74 | * Shorthand helper for SSR when using a root component reference. 75 | * 76 | * const html = ssr(() => ); 77 | */ 78 | export function ssr(entry: () => Child): string { 79 | return renderToStringRoot(entry()); 80 | } 81 | 82 | /* -------------------------------------------------------------------------- */ 83 | /* Re-exports */ 84 | /* -------------------------------------------------------------------------- */ 85 | 86 | /** Preconfigured HTML string renderer (UniversalNode based). */ 87 | export { SERVER_RENDERER }; 88 | 89 | /** Factory helpers for building custom server adapters/renderers. */ 90 | export { 91 | createDomAdapter, // intentionally exposed: allows hybrid/isomorphic patterns 92 | createRenderer, 93 | createServerStringAdapter, 94 | }; 95 | 96 | /** Low-level universal creation helpers (NOT the same as DOM Radi createElement). */ 97 | export { 98 | renderToString, 99 | ssrCreateComment as createComment, 100 | ssrCreateElement as createElement, 101 | ssrCreateTextNode as createTextNode, 102 | ssrFragment as Fragment, 103 | }; 104 | 105 | /* -------------------------------------------------------------------------- */ 106 | /* Guidance */ 107 | /* -------------------------------------------------------------------------- */ 108 | /* 109 | Distinguishing APIs: 110 | 111 | - createElement (from this module): 112 | Universal renderer element/component creation for SSR string output. 113 | Does not install reactive subscriptions beyond initial synchronous pass. 114 | 115 | - renderToString / renderToStringRoot: 116 | Produce an HTML string snapshot of the current tree. 117 | 118 | - ssr(): 119 | Convenience wrapper to invoke an entry component factory. 120 | 121 | If you need client-side interactivity or lifecycle events, import from 'radi/client' 122 | and hydrate manually (hydration not yet implemented in this abstraction). 123 | */ 124 | -------------------------------------------------------------------------------- /src/bench/table/frameworks/preact.tsx: -------------------------------------------------------------------------------- 1 | /** @jsxRuntime classic */ 2 | /** @jsx h */ 3 | /** @jsxFrag Fragment */ 4 | import { batch, signal, useComputed } from "npm:@preact/signals"; 5 | import { For } from "npm:@preact/signals/utils"; 6 | import { Fragment, h, render } from "npm:preact"; 7 | 8 | export const title = "Preact Signals Keyed"; 9 | 10 | let idCounter = 1; 11 | const adjectives = [ 12 | "pretty", 13 | "large", 14 | "big", 15 | "small", 16 | "tall", 17 | "short", 18 | "long", 19 | "handsome", 20 | "plain", 21 | "quaint", 22 | "clean", 23 | "elegant", 24 | "easy", 25 | "angry", 26 | "crazy", 27 | "helpful", 28 | "mushy", 29 | "odd", 30 | "unsightly", 31 | "adorable", 32 | "important", 33 | "inexpensive", 34 | "cheap", 35 | "expensive", 36 | "fancy", 37 | ], 38 | colours = [ 39 | "red", 40 | "yellow", 41 | "blue", 42 | "green", 43 | "pink", 44 | "brown", 45 | "purple", 46 | "brown", 47 | "white", 48 | "black", 49 | "orange", 50 | ], 51 | nouns = [ 52 | "table", 53 | "chair", 54 | "house", 55 | "bbq", 56 | "desk", 57 | "car", 58 | "pony", 59 | "cookie", 60 | "sandwich", 61 | "burger", 62 | "pizza", 63 | "mouse", 64 | "keyboard", 65 | ]; 66 | 67 | function _random(max) { 68 | return Math.round(Math.random() * 1000) % max; 69 | } 70 | 71 | function buildData(count) { 72 | let data = new Array(count); 73 | for (let i = 0; i < count; i++) { 74 | const label = signal( 75 | `${adjectives[_random(adjectives.length)]} ${ 76 | colours[_random(colours.length)] 77 | } ${nouns[_random(nouns.length)]}`, 78 | ); 79 | data[i] = { 80 | id: idCounter++, 81 | label, 82 | }; 83 | } 84 | return data; 85 | } 86 | 87 | const Button = ({ id, text, fn }) => ( 88 |
89 | 97 |
98 | ); 99 | const data = signal([]); 100 | const selected = signal(null); 101 | 102 | const run = () => { 103 | data.value = buildData(1000); 104 | }, 105 | runLots = () => (data.value = buildData(10000)), 106 | add = () => { 107 | data.value = data.value.concat(buildData(1000)); 108 | }, 109 | update = () => 110 | batch(() => { 111 | for (let i = 0, d = data.value, len = d.length; i < len; i += 10) { 112 | d[i].label.value = d[i].label.value + " !!!"; 113 | } 114 | }), 115 | swapRows = () => { 116 | const d = data.value.slice(); 117 | if (d.length > 998) { 118 | let tmp = { ...d[1] }; 119 | d[1] = { ...d[998] }; 120 | d[998] = tmp; 121 | data.value = d; 122 | } 123 | }, 124 | clear = () => (data.value = []), 125 | remove = (id) => { 126 | const idx = data.value.findIndex((d) => d.id === id); 127 | data.value = [...data.value.slice(0, idx), ...data.value.slice(idx + 1)]; 128 | }, 129 | select = (id) => { 130 | selected.value = id; 131 | }; 132 | 133 | const Row = ({ id, label }) => { 134 | const rowClass = useComputed(() => selected.value === id ? "danger" : ""); 135 | return ( 136 | 137 | 138 | 139 | select(id)} textContent={label} /> 140 | 141 | 142 | remove(id)}> 143 | 145 | 146 | 147 | 148 | ); 149 | }; 150 | 151 | const App = () => { 152 | return ( 153 |
154 |
155 |
156 |
157 |

Preact Signals Keyed

158 |
159 |
160 |
161 |
168 |
169 |
170 |
171 | 172 | 173 | 174 | {(row) => } 175 | 176 | 177 |
178 |
183 | ); 184 | }; 185 | 186 | render(, document.body); 187 | -------------------------------------------------------------------------------- /src/channel.ts: -------------------------------------------------------------------------------- 1 | import { update } from "./client.ts"; 2 | 3 | type Updater = T | ((prev: T | undefined) => T); 4 | 5 | interface ChannelAccessor { 6 | (): T; 7 | set(next: Updater): void; 8 | update(): void; 9 | readonly provider: Element | null; 10 | readonly resolved: boolean; 11 | } 12 | 13 | interface ChannelContainer { 14 | value: T; 15 | provider: Element; 16 | disposed: boolean; 17 | accessor: ChannelAccessor; 18 | } 19 | 20 | interface Channel { 21 | provide(root: Node, initial: Updater): ChannelAccessor; 22 | use(root: Node): ChannelAccessor; 23 | key: symbol; 24 | defaultValue: T; 25 | } 26 | 27 | const CHANNELS_SYMBOL = Symbol("radi:channels"); 28 | 29 | function getChannelMap(el: any): Map> { 30 | if (!el[CHANNELS_SYMBOL]) el[CHANNELS_SYMBOL] = new Map(); 31 | return el[CHANNELS_SYMBOL]; 32 | } 33 | 34 | export function createChannel(defaultValue: T): Channel { 35 | const key = Symbol("channel"); 36 | 37 | function resolveInitial(prev: T | undefined, init: Updater): T { 38 | return typeof init === "function" 39 | ? (init as (p: T | undefined) => T)(prev) 40 | : (init as T); 41 | } 42 | 43 | function makeAccessor( 44 | container: ChannelContainer, 45 | ): ChannelAccessor { 46 | const fn: any = () => container.value; 47 | Object.defineProperties(fn, { 48 | provider: { get: () => container.provider }, 49 | resolved: { get: () => true }, 50 | }); 51 | fn.set = (next: Updater) => { 52 | if (container.disposed) return; 53 | const prev = container.value; 54 | const val = typeof next === "function" 55 | ? (next as (p: T2) => T2)(prev) 56 | : (next as T2); 57 | if (val !== prev) { 58 | container.value = val; 59 | update(container.provider); 60 | } 61 | }; 62 | fn.update = () => { 63 | if (!container.disposed) update(container.provider); 64 | }; 65 | return fn; 66 | } 67 | 68 | function provide(root: Element, initial: Updater): ChannelAccessor { 69 | const map = getChannelMap(root); 70 | let container = map.get(key) as ChannelContainer | undefined; 71 | if (!container) { 72 | const value = resolveInitial(undefined, initial); 73 | container = { 74 | value, 75 | provider: root, 76 | disposed: false, 77 | accessor: undefined as any, 78 | }; 79 | container.accessor = makeAccessor(container); 80 | map.set(key, container); 81 | root.addEventListener( 82 | "disconnect", 83 | () => { 84 | container!.disposed = true; 85 | }, 86 | { once: true }, 87 | ); 88 | } else if (!container.disposed) { 89 | const next = resolveInitial(container.value, initial); 90 | if (next !== container.value) { 91 | container.value = next; 92 | update(container.provider); 93 | } 94 | } 95 | return container.accessor; 96 | } 97 | 98 | function findNearest(start: Element): ChannelContainer | null { 99 | let cur: any = start; 100 | while (cur) { 101 | const map: Map> | undefined = 102 | cur[CHANNELS_SYMBOL]; 103 | if (map && map.has(key)) { 104 | const c = map.get(key)!; 105 | if (!c.disposed) return c; 106 | } 107 | cur = cur.parentNode; 108 | } 109 | return null; 110 | } 111 | 112 | function use(root: Element): ChannelAccessor { 113 | let container: ChannelContainer | null = null; 114 | let cached = defaultValue; 115 | let resolved = false; 116 | 117 | const fn: any = () => { 118 | if (!resolved && root.isConnected) attemptResolve(); 119 | return container ? container.value : cached; 120 | }; 121 | 122 | function attemptResolve() { 123 | if (resolved) return; 124 | container = findNearest(root); 125 | if (container) { 126 | resolved = true; 127 | cached = container.value; 128 | update(root); // Re-render if we were showing default 129 | } 130 | } 131 | 132 | Object.defineProperties(fn, { 133 | provider: { get: () => (container ? container.provider : null) }, 134 | resolved: { get: () => resolved }, 135 | }); 136 | 137 | fn.set = (next: Updater) => { 138 | if (!container) return; // ignore until resolved 139 | const prev = container.value; 140 | const val = typeof next === "function" 141 | ? (next as (p: T) => T)(prev) 142 | : (next as T); 143 | if (val !== prev) { 144 | container.value = val; 145 | update(container.provider); 146 | } 147 | }; 148 | 149 | fn.update = () => { 150 | if (container) update(container.provider); 151 | }; 152 | 153 | if (root.isConnected) { 154 | attemptResolve(); 155 | } else { 156 | root.addEventListener( 157 | "connect", 158 | () => { 159 | attemptResolve(); 160 | }, 161 | { once: true }, 162 | ); 163 | } 164 | 165 | return fn as ChannelAccessor; 166 | } 167 | 168 | return { provide, use, key, defaultValue }; 169 | } 170 | -------------------------------------------------------------------------------- /src/tests/tabber-form.test.tsx: -------------------------------------------------------------------------------- 1 | import { assert, test } from "@marcisbee/rion/test"; 2 | import { mount } from "../../test/utils.ts"; 3 | import { update } from "../client.ts"; 4 | 5 | /** 6 | * Tab1 7 | * Static tab content component. 8 | * @param this HTMLElement host. 9 | * @returns JSX element with Tab1 label. 10 | */ 11 | function Tab1(this: HTMLElement) { 12 | return
Tab1
; 13 | } 14 | 15 | /** 16 | * Tab2 17 | * Form tab that collects submitted "event" values and re-renders a list. 18 | * Maintains an internal immutable event list. 19 | * @param this HTMLElement host. 20 | * @returns JSX form element with input + submitted events list. 21 | */ 22 | function Tab2(this: HTMLElement) { 23 | let eventList: string[] = []; 24 | 25 | return ( 26 |
{ 29 | e.preventDefault(); 30 | const fd = new FormData(e.target as HTMLFormElement); 31 | const obj = Object.fromEntries(fd.entries()); 32 | eventList = [...eventList, String(obj.event)]; 33 | (e.target as HTMLFormElement).reset(); 34 | update(this); 35 | }} 36 | > 37 | 38 | 41 |
    42 | {() => eventList.map((ev) =>
  • {ev}
  • )} 43 |
44 |
45 | ); 46 | } 47 | 48 | /** 49 | * TabberTest 50 | * Root container that controls which tab is active. 51 | * @param this HTMLElement host. 52 | * @returns JSX tab switcher + panel. 53 | */ 54 | function TabberTest(this: HTMLElement) { 55 | let activeTab: "tab1" | "tab2" = "tab1"; 56 | 57 | return ( 58 |
59 | 69 | 79 |
80 | {() => (activeTab === "tab1" ? : )} 81 |
82 |
83 | ); 84 | } 85 | 86 | /** switch tabs and submit events */ 87 | test("switch tabs", async () => { 88 | const rootEl = await mount(, document.body); 89 | const panelEl = rootEl.querySelector(".panel")!; 90 | assert.true(panelEl.textContent!.includes("Tab1")); 91 | 92 | (rootEl.querySelector(".btn-tab2") as HTMLButtonElement).click(); 93 | await Promise.resolve(); 94 | assert.elementExists(".tab2-form"); 95 | 96 | const eventInput = panelEl.querySelector(".event-input") as HTMLInputElement; 97 | const submitBtn = panelEl.querySelector(".submit-btn") as HTMLButtonElement; 98 | 99 | eventInput.value = "alpha"; 100 | submitBtn.click(); 101 | eventInput.value = "beta"; 102 | submitBtn.click(); 103 | 104 | const listItems = panelEl.querySelectorAll("li"); 105 | assert.equal(listItems.length, 2); 106 | assert.equal(listItems[0].textContent, "alpha"); 107 | assert.equal(listItems[1].textContent, "beta"); 108 | }); 109 | 110 | /** tab2 resets events after unmount/remount */ 111 | test("tab2 resets", async () => { 112 | const rootEl = await mount(, document.body); 113 | const btnTab1 = rootEl.querySelector(".btn-tab1") as HTMLButtonElement; 114 | const btnTab2 = rootEl.querySelector(".btn-tab2") as HTMLButtonElement; 115 | 116 | btnTab2.click(); 117 | await Promise.resolve(); 118 | 119 | const panelEl1 = rootEl.querySelector(".panel")!; 120 | const eventInput1 = panelEl1.querySelector( 121 | ".event-input", 122 | ) as HTMLInputElement; 123 | const submitBtn1 = panelEl1.querySelector(".submit-btn") as HTMLButtonElement; 124 | 125 | eventInput1.value = "first"; 126 | submitBtn1.click(); 127 | eventInput1.value = "second"; 128 | submitBtn1.click(); 129 | assert.length(panelEl1.querySelectorAll("li"), 2); 130 | 131 | btnTab1.click(); 132 | await Promise.resolve(); 133 | assert.true(panelEl1.textContent!.includes("Tab1")); 134 | 135 | btnTab2.click(); 136 | await Promise.resolve(); 137 | const panelEl2 = rootEl.querySelector(".panel")!; 138 | assert.length(panelEl2.querySelectorAll("li"), 0); 139 | }); 140 | 141 | /** empty submission preserved as empty list item */ 142 | test("empty submit preserved", async () => { 143 | const rootEl = await mount(, document.body); 144 | (rootEl.querySelector(".btn-tab2") as HTMLButtonElement).click(); 145 | await Promise.resolve(); 146 | 147 | const panelEl = rootEl.querySelector(".panel")!; 148 | const eventInput = panelEl.querySelector(".event-input") as HTMLInputElement; 149 | const submitBtn = panelEl.querySelector(".submit-btn") as HTMLButtonElement; 150 | 151 | eventInput.value = ""; 152 | submitBtn.click(); 153 | await Promise.resolve(); 154 | 155 | const listItems = panelEl.querySelectorAll("li"); 156 | assert.length(listItems, 1); 157 | assert.equal(listItems[0].textContent, ""); 158 | }); 159 | 160 | await test.run(); 161 | -------------------------------------------------------------------------------- /src/tests/server/ssr-escaping.test.tsx: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | import { assert, test } from "@marcisbee/rion/test"; 3 | import { 4 | createElement as h, 5 | Fragment, 6 | renderToStringRoot, 7 | } from "../../server.ts"; 8 | 9 | /** 10 | * SSR escaping & special character tests for Radi (rion test runner). 11 | * 12 | * Validates: 13 | * - Attribute values are properly escaped (& < > ") 14 | * - Text node content is escaped 15 | * - Nested components & fragments escape inner text 16 | * - Pre-escaped strings (idempotent behavior captured) 17 | * - Non-string attribute serialization (number/boolean/null/object) 18 | * - Function-valued props are omitted 19 | * - Comment node markers for boolean/null primitives 20 | */ 21 | 22 | /* -------------------------------------------------------------------------- */ 23 | /* Local assertion helpers */ 24 | /* -------------------------------------------------------------------------- */ 25 | 26 | function includes(html: string, fragment: string) { 27 | assert.equal( 28 | html.includes(fragment), 29 | true, 30 | `Expected HTML to include fragment:\n${fragment}\n---\nHTML:\n${html}`, 31 | ); 32 | } 33 | 34 | function notIncludes(html: string, fragment: string) { 35 | assert.equal( 36 | html.includes(fragment), 37 | false, 38 | `Did not expect HTML to include fragment:\n${fragment}\n---\nHTML:\n${html}`, 39 | ); 40 | } 41 | 42 | /* -------------------------------------------------------------------------- */ 43 | /* Components */ 44 | /* -------------------------------------------------------------------------- */ 45 | 46 | function Echo(props: any) { 47 | return h("span", null, props().value); 48 | } 49 | 50 | function Wrapper(props: any) { 51 | return h( 52 | "div", 53 | { title: props().raw }, 54 | h(Echo, { value: props().raw }), 55 | h(Fragment, null, props().raw, " / ", h("b", null, props().raw)), 56 | ); 57 | } 58 | 59 | /* -------------------------------------------------------------------------- */ 60 | /* Tests */ 61 | /* -------------------------------------------------------------------------- */ 62 | 63 | test('ssr: attribute escaping of <, >, &, "', () => { 64 | const raw = '
& " > <'; 65 | const html = renderToStringRoot( 66 | h("p", { "data-raw": raw, title: raw }, "content"), 67 | ); 68 | // Appears twice: data-raw and title 69 | const escapedAttr = 70 | "<div class="x&y"> & " > <"; 71 | includes(html, `data-raw="${escapedAttr}"`); 72 | includes(html, `title="${escapedAttr}"`); 73 | includes(html, "

content

"); 75 | }); 76 | 77 | test("ssr: text content escaping retains structure", () => { 78 | const raw = 'A&B '; 79 | const html = renderToStringRoot( 80 | h("section", null, raw, h("em", null, raw)), 81 | ); 82 | const escaped = "A&B <tag "quote">"; 83 | includes(html, escaped); 84 | includes(html, `${escaped}`); 85 | }); 86 | 87 | test("ssr: nested component & fragment escaping", () => { 88 | const raw = '<&"nested">'; 89 | const html = renderToStringRoot( 90 | h(Wrapper, { raw }), 91 | ); 92 | const escaped = "<&"nested">"; 93 | // Client parity: host wrappers only (no fragment boundary comments) 94 | includes(html, ""); 95 | notIncludes(html, ""); 96 | notIncludes(html, ""); 97 | // Escaped attribute 98 | includes(html, `title="${escaped}"`); 99 | // Echo span 100 | includes(html, `${escaped}`); 101 | // Fragment inner 102 | includes(html, `${escaped}`); 103 | }); 104 | 105 | test("ssr: idempotent escaping (pre-escaped string double-escapes)", () => { 106 | const alreadyEscaped = "<safe>&"; 107 | const html = renderToStringRoot( 108 | h("div", { title: alreadyEscaped }, alreadyEscaped), 109 | ); 110 | // Current serializer re-escapes ampersands 111 | includes(html, 'title="&lt;safe&gt;&amp;"'); 112 | includes(html, ">&lt;safe&gt;&amp;
"); 113 | }); 114 | 115 | test("ssr: non-string attribute serialization & function omission", () => { 116 | const html = renderToStringRoot( 117 | h("div", { 118 | num: 123, 119 | boolTrue: true, 120 | boolFalse: false, 121 | nil: null, 122 | obj: { a: 1 }, // becomes [object Object] 123 | fn: () => "ignored", // should not serialize 124 | }, "x"), 125 | ); 126 | includes(html, 'num="123"'); 127 | includes(html, 'boolTrue=""'); // client parity: boolean true renders as empty attribute 128 | notIncludes(html, 'boolFalse="false"'); 129 | // Null attribute omitted in client parity 130 | notIncludes(html, 'nil="null"'); 131 | includes(html, 'obj="[object Object]"'); 132 | notIncludes(html, "fn="); 133 | }); 134 | 135 | test("ssr: comment nodes for boolean/null primitives", () => { 136 | const html = renderToStringRoot( 137 | h("div", null, true, false, null), 138 | ); 139 | includes(html, ""); 140 | includes(html, ""); 141 | includes(html, ""); 142 | }); 143 | 144 | /* -------------------------------------------------------------------------- */ 145 | /* Run */ 146 | /* -------------------------------------------------------------------------- */ 147 | 148 | await test.run(); 149 | -------------------------------------------------------------------------------- /src/bench/table/frameworks/react.tsx: -------------------------------------------------------------------------------- 1 | /** @jsxRuntime classic */ 2 | /** @jsx React.createElement */ 3 | /** @jsxFrag React.Fragment */ 4 | import React, { memo, useReducer } from "npm:react"; 5 | import { createRoot } from "npm:react-dom/client"; 6 | 7 | const random = (max) => Math.round(Math.random() * 1000) % max; 8 | 9 | const A = [ 10 | "pretty", 11 | "large", 12 | "big", 13 | "small", 14 | "tall", 15 | "short", 16 | "long", 17 | "handsome", 18 | "plain", 19 | "quaint", 20 | "clean", 21 | "elegant", 22 | "easy", 23 | "angry", 24 | "crazy", 25 | "helpful", 26 | "mushy", 27 | "odd", 28 | "unsightly", 29 | "adorable", 30 | "important", 31 | "inexpensive", 32 | "cheap", 33 | "expensive", 34 | "fancy", 35 | ]; 36 | const C = [ 37 | "red", 38 | "yellow", 39 | "blue", 40 | "green", 41 | "pink", 42 | "brown", 43 | "purple", 44 | "brown", 45 | "white", 46 | "black", 47 | "orange", 48 | ]; 49 | const N = [ 50 | "table", 51 | "chair", 52 | "house", 53 | "bbq", 54 | "desk", 55 | "car", 56 | "pony", 57 | "cookie", 58 | "sandwich", 59 | "burger", 60 | "pizza", 61 | "mouse", 62 | "keyboard", 63 | ]; 64 | 65 | let nextId = 1; 66 | 67 | const buildData = (count) => { 68 | const data = new Array(count); 69 | 70 | for (let i = 0; i < count; i++) { 71 | data[i] = { 72 | id: nextId++, 73 | label: `${A[random(A.length)]} ${C[random(C.length)]} ${ 74 | N[random(N.length)] 75 | }`, 76 | }; 77 | } 78 | 79 | return data; 80 | }; 81 | 82 | const initialState = { data: [], selected: 0 }; 83 | 84 | const listReducer = (state, action) => { 85 | const { data, selected } = state; 86 | 87 | switch (action.type) { 88 | case "RUN": 89 | return { data: buildData(1000), selected: 0 }; 90 | case "RUN_LOTS": 91 | return { data: buildData(10000), selected: 0 }; 92 | case "ADD": 93 | return { data: data.concat(buildData(1000)), selected }; 94 | case "UPDATE": { 95 | const newData = data.slice(0); 96 | 97 | for (let i = 0; i < newData.length; i += 10) { 98 | const r = newData[i]; 99 | 100 | newData[i] = { id: r.id, label: r.label + " !!!" }; 101 | } 102 | 103 | return { data: newData, selected }; 104 | } 105 | case "CLEAR": 106 | return { data: [], selected: 0 }; 107 | case "SWAP_ROWS": 108 | const newdata = [...data]; 109 | if (data.length > 998) { 110 | const d1 = newdata[1]; 111 | const d998 = newdata[998]; 112 | newdata[1] = d998; 113 | newdata[998] = d1; 114 | } 115 | return { data: newdata, selected }; 116 | case "REMOVE": { 117 | const idx = data.findIndex((d) => d.id === action.id); 118 | 119 | return { 120 | data: [...data.slice(0, idx), ...data.slice(idx + 1)], 121 | selected, 122 | }; 123 | } 124 | case "SELECT": 125 | return { data, selected: action.id }; 126 | default: 127 | return state; 128 | } 129 | }; 130 | 131 | const Row = memo( 132 | ({ selected, item, dispatch }) => ( 133 | 134 | {item.id} 135 | 136 | dispatch({ type: "SELECT", id: item.id })}> 137 | {item.label} 138 | 139 | 140 | 141 | dispatch({ type: "REMOVE", id: item.id })} 143 | > 144 | 146 | 147 | 148 | 149 | ), 150 | (prevProps, nextProps) => 151 | prevProps.selected === nextProps.selected && 152 | prevProps.item === nextProps.item, 153 | ); 154 | 155 | const Button = ({ id, cb, title }) => ( 156 |
157 | 165 |
166 | ); 167 | 168 | const Jumbotron = memo(({ dispatch }) => ( 169 |
170 |
171 |
172 |

React Hooks keyed

173 |
174 |
175 |
176 |
207 |
208 |
209 |
210 | ), () => true); 211 | 212 | const Main = () => { 213 | const [{ data, selected }, dispatch] = useReducer(listReducer, initialState); 214 | 215 | return ( 216 |
217 | 218 | 219 | 220 | {data.map((item) => ( 221 | 227 | ))} 228 | 229 |
230 |
235 | ); 236 | }; 237 | 238 | createRoot(document.body).render(
); 239 | -------------------------------------------------------------------------------- /src/tests/keyed-elements.test.tsx: -------------------------------------------------------------------------------- 1 | import { assert, test } from "@marcisbee/rion/test"; 2 | import { mount } from "../../test/utils.ts"; 3 | import { createList, update } from "../client.ts"; 4 | import { locator } from "@marcisbee/rion/locator"; 5 | 6 | /** keyed reorder preserves node identity while changing order */ 7 | test("keyed-reorder-preserves-instances", async () => { 8 | let items = [ 9 | { id: "a" }, 10 | { id: "b" }, 11 | ]; 12 | 13 | function KeyedReorderRoot() { 14 | return ( 15 |
    16 | {() => 17 | createList((key) => 18 | items.map((item) => 19 | key(() =>
  • {item.id}
  • , item.id) 20 | ) 21 | )} 22 |
23 | ); 24 | } 25 | 26 | const root = await mount(, document.body); 27 | 28 | const firstRenderNodes = Array.from( 29 | root.querySelectorAll(".key-item"), 30 | ) as HTMLElement[]; 31 | assert.equal(firstRenderNodes.length, 2); 32 | 33 | items = items.reverse(); 34 | update(root); 35 | 36 | const secondRenderNodes = Array.from( 37 | root.querySelectorAll(".key-item"), 38 | ) as HTMLElement[]; 39 | assert.equal(secondRenderNodes.length, 2); 40 | 41 | // Nodes should be reused but in reversed order 42 | assert.equal(firstRenderNodes[0], secondRenderNodes[1]); 43 | assert.equal(firstRenderNodes[1], secondRenderNodes[0]); 44 | }); 45 | 46 | /** keyed removal removes only target key and preserves others */ 47 | test("keyed-removal-preserves-others", async () => { 48 | let items = [ 49 | { id: "a" }, 50 | { id: "b" }, 51 | { id: "c" }, 52 | ]; 53 | 54 | function KeyedRemovalRoot() { 55 | return ( 56 |
    57 | {() => 58 | createList((key) => 59 | items.map((item) => 60 | key( 61 | () => ( 62 |
  • 63 | {item.id} 64 |
  • 65 | ), 66 | item.id, 67 | ) 68 | ) 69 | )} 70 |
71 | ); 72 | } 73 | 74 | const root = await mount(, document.body); 75 | 76 | const before = Array.from(root.querySelectorAll("li")) as HTMLElement[]; 77 | assert.equal(before.length, 3); 78 | const aNode = before.find((n) => n.textContent === "a")!; 79 | const bNode = before.find((n) => n.textContent === "b")!; 80 | const cNode = before.find((n) => n.textContent === "c")!; 81 | 82 | items = items.filter((item) => item.id !== "b"); 83 | update(root); 84 | 85 | const after = Array.from(root.querySelectorAll("li")) as HTMLElement[]; 86 | assert.equal(after.length, 2); 87 | const texts = after.map((n) => n.textContent); 88 | assert.excludes(texts, "b"); 89 | assert.true(after.includes(aNode)); 90 | assert.true(after.includes(cNode)); 91 | assert.true(!document.body.contains(bNode), "Removed node not in DOM"); 92 | }); 93 | 94 | test("keyed reorder only 2, don't re-render rest", async () => { 95 | let items = [ 96 | { id: "a" }, 97 | { id: "b" }, 98 | { id: "c" }, 99 | { id: "d" }, 100 | ]; 101 | 102 | function Test() { 103 | let renderCount = 0; 104 | return ( 105 |
    106 | {() => 107 | createList((key) => 108 | items.map((item) => 109 | key( 110 | () => ( 111 |
  • 112 | {renderCount++} 113 | {item.id} 114 |
  • 115 | ), 116 | item.id, 117 | ) 118 | ) 119 | )} 120 |
121 | ); 122 | } 123 | 124 | const root = await mount(, document.body); 125 | 126 | assert.snapshot.html( 127 | root, 128 | ` 129 | 130 |
    131 | 132 |
  • 133 | 0 134 | a 135 |
  • 136 |
  • 137 | 1 138 | b 139 |
  • 140 |
  • 141 | 2 142 | c 143 |
  • 144 |
  • 145 | 3 146 | d 147 |
  • 148 |
149 |
150 | `, 151 | ); 152 | 153 | items = items = [ 154 | { id: "c" }, 155 | { id: "b" }, 156 | { id: "a" }, 157 | { id: "d" }, 158 | ]; 159 | update(root); 160 | 161 | assert.snapshot.html( 162 | root, 163 | ` 164 | 165 |
    166 | 167 |
  • 168 | 4 169 | c 170 |
  • 171 |
  • 172 | 1 173 | b 174 |
  • 175 |
  • 176 | 5 177 | a 178 |
  • 179 |
  • 180 | 3 181 | d 182 |
  • 183 |
184 |
185 | `, 186 | ); 187 | }); 188 | 189 | test("keyed reorder only 2, preserve instances", async () => { 190 | let items = [ 191 | { id: "a" }, 192 | { id: "b" }, 193 | { id: "c" }, 194 | { id: "d" }, 195 | ]; 196 | 197 | function Test() { 198 | let renderCount = 0; 199 | return ( 200 |
    201 | {() => 202 | createList((key) => 203 | items.map((item) => 204 | key( 205 | () => ( 206 |
  • 207 | {renderCount++} 208 | {item.id} 209 |
  • 210 | ), 211 | item.id, 212 | ) 213 | ) 214 | )} 215 |
216 | ); 217 | } 218 | 219 | const root = await mount(, document.body); 220 | 221 | const li1 = await locator("li", root).getAll(); 222 | 223 | items = items = [ 224 | { id: "c" }, 225 | { id: "b" }, 226 | { id: "a" }, 227 | { id: "d" }, 228 | ]; 229 | update(root); 230 | 231 | const li2 = await locator("li", root).getAll(); 232 | 233 | assert.equal(li1[0], li2[2]); 234 | assert.equal(li1[1], li2[1]); 235 | assert.equal(li1[3], li2[3]); 236 | }); 237 | 238 | await test.run(); 239 | -------------------------------------------------------------------------------- /src/tests/server/server-client-parity.test.tsx: -------------------------------------------------------------------------------- 1 | import { assert, test } from "@marcisbee/rion/test"; 2 | import { 3 | createElement as server, 4 | Fragment as serverFragment, 5 | renderToStringRoot, 6 | } from "../../server.ts"; 7 | import { 8 | createElement as client, 9 | createRoot, 10 | Fragment as clientFragment, 11 | } from "../../client.ts"; 12 | 13 | import * as Server from "./components.server.tsx"; 14 | import * as Client from "./components.client.tsx"; 15 | 16 | test("matches simple node", () => { 17 | const htmlServer = renderToStringRoot( 18 | server("div", null), 19 | ); 20 | 21 | const htmlClient = createRoot(document.body).render( 22 | client("div", null), 23 | ); 24 | 25 | assert.snapshot.html( 26 | htmlServer, 27 | htmlClient, 28 | ); 29 | }); 30 | 31 | test("matches simple node with children", () => { 32 | const htmlServer = renderToStringRoot( 33 | server("div", null, "Hello", " ", "World"), 34 | ); 35 | 36 | const htmlClient = createRoot(document.body).render( 37 | client("div", null, "Hello", " ", "World"), 38 | ); 39 | 40 | assert.snapshot.html( 41 | htmlServer, 42 | htmlClient, 43 | ); 44 | }); 45 | 46 | test("matches simple node with children & props", () => { 47 | const htmlServer = renderToStringRoot( 48 | server( 49 | "div", 50 | { className: "foo", style: { color: "red" } }, 51 | "Hello", 52 | " ", 53 | "World", 54 | ), 55 | ); 56 | 57 | const htmlClient = createRoot(document.body).render( 58 | client( 59 | "div", 60 | { className: "foo", style: { color: "red" } }, 61 | "Hello", 62 | " ", 63 | "World", 64 | ), 65 | ); 66 | 67 | assert.snapshot.html( 68 | htmlServer, 69 | htmlClient, 70 | ); 71 | }); 72 | 73 | test("matches simple node with nested children & props", () => { 74 | const htmlServer = renderToStringRoot( 75 | server( 76 | "div", 77 | { id: "parent" }, 78 | "Hello", 79 | " ", 80 | server("strong", null, "Radi"), 81 | ), 82 | ); 83 | 84 | const htmlClient = createRoot(document.body).render( 85 | client( 86 | "div", 87 | { id: "parent" }, 88 | "Hello", 89 | " ", 90 | client("strong", null, "Radi"), 91 | ), 92 | ); 93 | 94 | assert.snapshot.html( 95 | htmlServer, 96 | htmlClient, 97 | ); 98 | }); 99 | 100 | test("matches simple app", () => { 101 | const htmlServer = renderToStringRoot( 102 | server(Server.App, null), 103 | ); 104 | 105 | const htmlClient = createRoot(document.body).render( 106 | client(Client.App, null), 107 | ); 108 | 109 | assert.snapshot.html( 110 | htmlServer, 111 | htmlClient, 112 | ); 113 | }); 114 | 115 | test("matches boolean and null children", () => { 116 | const htmlServer = renderToStringRoot( 117 | server("div", null, true, null, false), 118 | ); 119 | 120 | const htmlClient = createRoot(document.body).render( 121 | client("div", null, true, null, false), 122 | ); 123 | 124 | assert.snapshot.html( 125 | htmlServer, 126 | htmlClient, 127 | ); 128 | }); 129 | 130 | test("matches style camelCase serialization", () => { 131 | const htmlServer = renderToStringRoot( 132 | server( 133 | "div", 134 | { style: { paddingLeft: 10, backgroundColor: "yellow" } }, 135 | "Styled", 136 | ), 137 | ); 138 | 139 | const htmlClient = createRoot(document.body).render( 140 | client( 141 | "div", 142 | { style: { paddingLeft: 10, backgroundColor: "yellow" } }, 143 | "Styled", 144 | ), 145 | ); 146 | 147 | assert.snapshot.html( 148 | htmlServer, 149 | htmlClient, 150 | ); 151 | }); 152 | 153 | test("matches nested component inside element", () => { 154 | const htmlServer = renderToStringRoot( 155 | server("section", null, server(Server.App, null)), 156 | ); 157 | 158 | const htmlClient = createRoot(document.body).render( 159 | client("section", null, client(Client.App, null)), 160 | ); 161 | 162 | assert.snapshot.html( 163 | htmlServer, 164 | htmlClient, 165 | ); 166 | }); 167 | 168 | test("matches null and numeric props", () => { 169 | const htmlServer = renderToStringRoot( 170 | server("div", { datanull: null, datanum: 0, databool: true }, "Values"), 171 | ); 172 | 173 | const htmlClient = createRoot(document.body).render( 174 | client("div", { datanull: null, datanum: 0, databool: true }, "Values"), 175 | ); 176 | 177 | assert.snapshot.html( 178 | htmlServer, 179 | htmlClient, 180 | ); 181 | }); 182 | 183 | test("matches fragment children", () => { 184 | const htmlServer = renderToStringRoot( 185 | server("div", null, server(serverFragment, null, "A", "B")), 186 | ); 187 | const htmlClient = createRoot(document.body).render( 188 | client("div", null, client(clientFragment, null, "A", "B")), 189 | ); 190 | assert.snapshot.html(htmlServer, htmlClient); 191 | }); 192 | 193 | test("matches nested fragments", () => { 194 | const htmlServer = renderToStringRoot( 195 | server( 196 | "div", 197 | null, 198 | server( 199 | serverFragment, 200 | null, 201 | "X", 202 | server(serverFragment, null, "Y", "Z"), 203 | ), 204 | ), 205 | ); 206 | const htmlClient = createRoot(document.body).render( 207 | client( 208 | "div", 209 | null, 210 | client( 211 | clientFragment, 212 | null, 213 | "X", 214 | client(clientFragment, null, "Y", "Z"), 215 | ), 216 | ), 217 | ); 218 | assert.snapshot.html(htmlServer, htmlClient); 219 | }); 220 | 221 | // Simple subscribable mock (synchronous initial emission) 222 | function makeSubscribable(initial: unknown) { 223 | return { 224 | subscribe(fn: (v: unknown) => void) { 225 | fn(initial); 226 | }, 227 | }; 228 | } 229 | 230 | test("matches subscribable child", () => { 231 | const sub = makeSubscribable("Sub"); 232 | const htmlServer = renderToStringRoot( 233 | server("div", null, sub), 234 | ); 235 | const htmlClient = createRoot(document.body).render( 236 | client("div", null, sub), 237 | ); 238 | assert.snapshot.html(htmlServer, htmlClient); 239 | }); 240 | 241 | test("matches reactive function child", () => { 242 | const htmlServer = renderToStringRoot( 243 | server("div", null, (_el: Element) => "R"), 244 | ); 245 | const htmlClient = createRoot(document.body).render( 246 | client("div", null, (_el: Element) => "R"), 247 | ); 248 | assert.snapshot.html(htmlServer, htmlClient); 249 | }); 250 | 251 | await test.run(); 252 | -------------------------------------------------------------------------------- /src/tests/prop-store-subscribe.test.tsx: -------------------------------------------------------------------------------- 1 | import { assert, test } from "@marcisbee/rion/test"; 2 | import { mount } from "../../test/utils.ts"; 3 | import { createRoot, update } from "../client.ts"; 4 | 5 | /** 6 | * Simple store factory returning a subscribable with: 7 | * - subscribe(fn) => cleanup variants 8 | * - set(value) to emit 9 | * Counts unsubscriptions for verification. 10 | */ 11 | function createStore( 12 | initial: T, 13 | emitInitial: boolean, 14 | ) { 15 | let current = initial; 16 | const subs = new Set<(v: T) => void>(); 17 | let unsubscribedCount = 0; 18 | const subscribe = (fn: (value: T) => void) => { 19 | subs.add(fn); 20 | if (emitInitial) fn(current); 21 | return { 22 | unsubscribe() { 23 | if (subs.delete(fn)) unsubscribedCount++; 24 | }, 25 | }; 26 | }; 27 | return { 28 | subscribe, 29 | set(value: T) { 30 | current = value; 31 | for (const s of subs) s(current); 32 | }, 33 | get value() { 34 | return current; 35 | }, 36 | get unsubscribedCount() { 37 | return unsubscribedCount; 38 | }, 39 | }; 40 | } 41 | 42 | /* ========================= Tests ========================= */ 43 | 44 | test("prop-store-initial-sync", async () => { 45 | const titleStore = createStore("Hello", true); 46 | 47 | function App() { 48 | return
x
; 49 | } 50 | 51 | const root = await mount(, document.body); 52 | const div = root.querySelector("#host") as HTMLDivElement; 53 | 54 | assert.equal(div.title, "Hello"); 55 | }); 56 | 57 | test("prop-store-subsequent-update", async () => { 58 | const dataStore = createStore("one", true); 59 | 60 | function App() { 61 | return y; 62 | } 63 | 64 | const root = await mount(, document.body); 65 | const span = root.querySelector("#host") as HTMLSpanElement; 66 | assert.equal(span.getAttribute("data-value"), "one"); 67 | 68 | dataStore.set("two"); 69 | assert.equal(span.getAttribute("data-value"), "two"); 70 | 71 | dataStore.set("three"); 72 | assert.equal(span.getAttribute("data-value"), "three"); 73 | }); 74 | 75 | test("prop-store-no-initial-emission", async () => { 76 | const lateStore = createStore("latent", false); 77 | 78 | function App() { 79 | return
z
; 80 | } 81 | 82 | const root = await mount(, document.body); 83 | const div = root.querySelector("#host") as HTMLDivElement; 84 | 85 | // No initial emission -> attribute absent 86 | assert.equal(div.getAttribute("data-mode"), null); 87 | 88 | lateStore.set("latent"); 89 | assert.equal(div.getAttribute("data-mode"), "latent"); 90 | 91 | lateStore.set("active"); 92 | assert.equal(div.getAttribute("data-mode"), "active"); 93 | }); 94 | 95 | test("prop-store-unsubscribe-function-cleanup", async () => { 96 | let store = "A"; 97 | const store1 = createStore("A", true); 98 | const store2 = createStore("B", true); 99 | 100 | function App() { 101 | return () => ( 102 |
103 | c 104 |
105 | ); 106 | } 107 | 108 | const root = createRoot(document.body); 109 | const host = root.render(); 110 | await Promise.resolve(); 111 | assert.equal(store1.unsubscribedCount, 0); 112 | 113 | store = "B"; 114 | update(host); 115 | await Promise.resolve(); 116 | assert.equal(store1.unsubscribedCount, 1); 117 | }); 118 | 119 | test("prop-store-unsubscribe-object-cleanup", async () => { 120 | const store = createStore("B", true); 121 | 122 | function App() { 123 | return
d
; 124 | } 125 | 126 | const root = createRoot(document.body); 127 | root.render(); 128 | await Promise.resolve(); 129 | assert.equal(store.unsubscribedCount, 0); 130 | 131 | root.unmount(); 132 | await Promise.resolve(); 133 | assert.equal(store.unsubscribedCount, 1); 134 | }); 135 | 136 | test("prop-store-updates-property-field", async () => { 137 | // Using 'value' prop on input element which is a direct property assignment path. 138 | const valueStore = createStore("first", true); 139 | 140 | function App() { 141 | return ; 142 | } 143 | 144 | const root = await mount(, document.body); 145 | const input = root.querySelector("#inp") as HTMLInputElement; 146 | assert.equal(input.value, "first"); 147 | 148 | valueStore.set("second"); 149 | assert.equal(input.value, "second"); 150 | 151 | valueStore.set("third"); 152 | assert.equal(input.value, "third"); 153 | }); 154 | 155 | test("prop-store-boolean-attribute-presence", async () => { 156 | // Use a boolean transformation store to toggle 'disabled' (property) and reflect change. 157 | const disabledStore = createStore(true, true); 158 | 159 | function App() { 160 | return ; 161 | } 162 | 163 | const root = await mount(, document.body); 164 | const button = root.querySelector("#btn") as HTMLButtonElement; 165 | 166 | assert.equal(button.disabled, true); 167 | 168 | disabledStore.set(false); 169 | assert.equal(button.disabled, false); 170 | 171 | disabledStore.set(true); 172 | assert.equal(button.disabled, true); 173 | }); 174 | 175 | test("prop-store-style-object-updates", async () => { 176 | const styleStore = createStore("red", true); 177 | 178 | function App() { 179 | return
style
; 180 | } 181 | 182 | const root = await mount(, document.body); 183 | const div = root.querySelector("#styled") as HTMLDivElement; 184 | assert.equal(div.style.color, "red"); 185 | 186 | styleStore.set("blue"); 187 | assert.equal(div.style.color, "blue"); 188 | 189 | styleStore.set("green"); 190 | assert.equal(div.style.color, "green"); 191 | }); 192 | 193 | test("prop-store-multiple-props", async () => { 194 | const titleStore = createStore("T1", true); 195 | const dataStore = createStore("D1", true); 196 | 197 | function App() { 198 | return
multi
; 199 | } 200 | 201 | const root = await mount(, document.body); 202 | const div = root.querySelector("#multi") as HTMLDivElement; 203 | 204 | assert.equal(div.title, "T1"); 205 | assert.equal(div.getAttribute("data-info"), "D1"); 206 | 207 | titleStore.set("T2"); 208 | dataStore.set("D2"); 209 | assert.equal(div.title, "T2"); 210 | assert.equal(div.getAttribute("data-info"), "D2"); 211 | }); 212 | 213 | await test.run(); 214 | -------------------------------------------------------------------------------- /src/tests/server/ssr-render.test.tsx: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | import { assert, test } from "@marcisbee/rion/test"; 3 | import { 4 | createElement as h, 5 | Fragment, 6 | renderToStringRoot, 7 | } from "../../server.ts"; 8 | import type { Child } from "../../types.ts"; 9 | 10 | /* -------------------------------------------------------------------------- */ 11 | /* Helpers */ 12 | /* -------------------------------------------------------------------------- */ 13 | 14 | function oneShot(value: T) { 15 | return { 16 | subscribe(fn: (v: T) => void) { 17 | fn(value); // single emission only 18 | }, 19 | }; 20 | } 21 | 22 | function multiShot(first: T, second: T) { 23 | return { 24 | subscribe(fn: (v: T) => void) { 25 | fn(first); 26 | fn(second); // second emission should be ignored by server one-shot logic 27 | }, 28 | }; 29 | } 30 | 31 | function includes(html: string, fragment: string) { 32 | assert.equal( 33 | html.includes(fragment), 34 | true, 35 | `Expected HTML to include: ${fragment}`, 36 | ); 37 | } 38 | function notIncludes(html: string, fragment: string) { 39 | assert.equal( 40 | html.includes(fragment), 41 | false, 42 | `Did not expect HTML to include: ${fragment}`, 43 | ); 44 | } 45 | 46 | /* -------------------------------------------------------------------------- */ 47 | /* Components */ 48 | /* -------------------------------------------------------------------------- */ 49 | 50 | function Echo(props: () => { value: string }) { 51 | return h("span", null, props().value); 52 | } 53 | 54 | function Wrapper(props: () => { label: string }) { 55 | return h( 56 | "div", 57 | { class: "wrap" }, 58 | h(Echo, { value: props().label }), 59 | h("strong", null, props().label.toUpperCase()), 60 | ); 61 | } 62 | 63 | function Leaf(props: () => { text: string; flag?: boolean }) { 64 | return h( 65 | "div", 66 | { "data-leaf": props().text }, 67 | props().text, 68 | 7, 69 | props().flag ?? false, 70 | null, 71 | h(Fragment, null, "frag-part", h("i", null, "italic")), 72 | ); 73 | } 74 | 75 | function Nest(props: () => { base: string }): Child { 76 | return [ 77 | h("header", null, "Header:", props().base), 78 | h(Wrapper, { label: props().base + "-wrap" }), 79 | h(Leaf, { text: props().base + "-leaf", flag: true }), 80 | h("footer", null, "Footer"), 81 | ]; 82 | } 83 | 84 | function ErrorComponent(_props: () => Record): Child { 85 | throw new Error("boom"); 86 | } 87 | 88 | /* -------------------------------------------------------------------------- */ 89 | /* Tests */ 90 | /* -------------------------------------------------------------------------- */ 91 | 92 | test("ssr: primitives & basic structure", () => { 93 | const html = renderToStringRoot( 94 | h( 95 | "div", 96 | { id: "root" }, 97 | "hello", 98 | 42, 99 | true, 100 | null, 101 | false, 102 | ), 103 | ); 104 | includes(html, '
'); 105 | includes(html, "hello"); 106 | includes(html, "42"); 107 | includes(html, ""); 108 | includes(html, ""); 109 | includes(html, ""); 110 | includes(html, "
"); 111 | }); 112 | 113 | test("ssr: nested components & fragment", () => { 114 | const html = renderToStringRoot( 115 | h("section", { id: "app" }, h(Nest, { base: "base" })), 116 | ); 117 | includes(html, '
'); 118 | includes(html, "Header:base"); 119 | includes(html, "base-wrap"); 120 | includes(html, "base-leaf"); 121 | includes(html, "frag-part"); 122 | includes(html, "italic"); 123 | includes(html, "
Footer
"); 124 | // Component wrappers (client parity host elements) 125 | const count = html.split("").length - 1; 126 | assert.equal(count >= 3, true, "Expected multiple component wrappers (host)"); 127 | }); 128 | 129 | test("ssr: fragment top-level wrapper", () => { 130 | const html = renderToStringRoot( 131 | h( 132 | Fragment, 133 | null, 134 | h("em", null, "a"), 135 | "b", 136 | h("strong", null, "c"), 137 | ), 138 | ); 139 | // Client parity: fragment renders raw children (no boundary comments). 140 | includes(html, "a"); 141 | includes(html, "b"); 142 | includes(html, "c"); 143 | }); 144 | 145 | test("ssr: attribute escaping", () => { 146 | const html = renderToStringRoot( 147 | h("div", { title: '<>&"' }, "x"), 148 | ); 149 | includes(html, 'title="<>&""'); 150 | includes(html, ">x
"); 151 | }); 152 | 153 | test("ssr: subscribable one-shot sampling", () => { 154 | const html = renderToStringRoot( 155 | h("section", null, multiShot("first", "second")), 156 | ); 157 | includes(html, "
"); 158 | // Server samples first subscribable emission; second emission is ignored. 159 | includes(html, "first"); 160 | notIncludes(html, "second"); 161 | }); 162 | 163 | test("ssr: component error fallback marker", () => { 164 | const html = renderToStringRoot( 165 | h("div", null, h(ErrorComponent, null)), 166 | ); 167 | includes(html, "ERROR:ErrorComponent"); 168 | notIncludes(html, " { 173 | const html = renderToStringRoot( 174 | h(Leaf, { text: "sample", flag: true }), 175 | ); 176 | includes(html, 'data-leaf="sample"'); 177 | includes(html, ""); // flag 178 | includes(html, ""); // explicit null child 179 | // Leaf does not emit a false marker when flag=true 180 | }); 181 | 182 | test("ssr: mixed types & component chain", () => { 183 | const html = renderToStringRoot( 184 | h( 185 | "main", 186 | null, 187 | h(Wrapper, { label: "mix" }), 188 | oneShot("sub-value"), 189 | h(Leaf, { text: "tail" }), 190 | ), 191 | ); 192 | includes(html, "
"); 193 | includes(html, "mix"); 194 | // One-shot subscribable value is sampled (first emission only). 195 | includes(html, "sub-value"); 196 | includes(html, "tail"); 197 | includes(html, "
"); 198 | }); 199 | 200 | /* -------------------------------------------------------------------------- */ 201 | /* Run */ 202 | /* -------------------------------------------------------------------------- */ 203 | 204 | await test.run(); 205 | -------------------------------------------------------------------------------- /src/tests/server/ssr-basic.test.tsx: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | import { assert, test } from "@marcisbee/rion/test"; 3 | import { 4 | createElement as h, 5 | Fragment, 6 | renderToStringRoot, 7 | } from "../../server.ts"; 8 | 9 | /** 10 | * Basic SSR tests for Radi server renderer using rion test runner. 11 | * 12 | * Covered: 13 | * - Primitive rendering (string, number, boolean, null) 14 | * - Nested component rendering 15 | * - Fragment handling 16 | * - Subscribable (one-shot) expansion 17 | * - Attribute escaping 18 | * - Component error fallback marker 19 | */ 20 | 21 | /* -------------------------------------------------------------------------- */ 22 | /* Helpers */ 23 | /* -------------------------------------------------------------------------- */ 24 | 25 | function oneShot(value) { 26 | return { 27 | subscribe(fn) { 28 | fn(value); // single emission only 29 | }, 30 | }; 31 | } 32 | 33 | function multiShot(first, second) { 34 | return { 35 | subscribe(fn) { 36 | fn(first); 37 | fn(second); // second emission should be ignored by server one-shot logic 38 | }, 39 | }; 40 | } 41 | 42 | function includes(html, fragment) { 43 | assert.equal( 44 | html.includes(fragment), 45 | true, 46 | `Expected HTML to include: ${fragment}`, 47 | ); 48 | } 49 | 50 | function notIncludes(html, fragment) { 51 | assert.equal( 52 | html.includes(fragment), 53 | false, 54 | `Did not expect HTML to include: ${fragment}`, 55 | ); 56 | } 57 | 58 | /* -------------------------------------------------------------------------- */ 59 | /* Components */ 60 | /* -------------------------------------------------------------------------- */ 61 | 62 | function Label(props) { 63 | return h("span", null, "Label:", props().text); 64 | } 65 | 66 | function Wrapper(props) { 67 | return h( 68 | "div", 69 | { class: "wrap" }, 70 | h(Label, { text: props().label }), 71 | h("strong", null, props().label.toUpperCase()), 72 | ); 73 | } 74 | 75 | function Leaf(props) { 76 | return h( 77 | "div", 78 | { "data-leaf": props().text }, 79 | props().text, 80 | 7, 81 | props().flag ?? false, 82 | null, 83 | h(Fragment, null, "frag-part", h("i", null, "italic")), 84 | ); 85 | } 86 | 87 | function Nest(props) { 88 | return [ 89 | h("header", null, "Header:", props().base), 90 | h(Wrapper, { label: props().base + "-wrap" }), 91 | h(Leaf, { text: props().base + "-leaf", flag: true }), 92 | h("footer", null, "Footer"), 93 | ]; 94 | } 95 | 96 | function ErrorComponent() { 97 | throw new Error("boom"); 98 | } 99 | 100 | /* -------------------------------------------------------------------------- */ 101 | /* Tests */ 102 | /* -------------------------------------------------------------------------- */ 103 | 104 | test("ssr: primitives & basic structure", () => { 105 | const html = renderToStringRoot( 106 | h( 107 | "div", 108 | { id: "root" }, 109 | "hello", 110 | 42, 111 | true, 112 | null, 113 | false, 114 | ), 115 | ); 116 | includes(html, '
'); 117 | includes(html, "hello"); 118 | includes(html, "42"); 119 | includes(html, ""); 120 | includes(html, ""); 121 | includes(html, ""); 122 | includes(html, "
"); 123 | }); 124 | 125 | test("ssr: nested components & fragment", () => { 126 | const html = renderToStringRoot( 127 | h("section", { id: "app" }, h(Nest, { base: "base" })), 128 | ); 129 | includes(html, '
'); 130 | includes(html, "Header:base"); 131 | includes(html, "base-wrap"); 132 | includes(html, "base-leaf"); 133 | includes(html, "frag-part"); 134 | includes(html, "italic"); 135 | includes(html, "
Footer
"); 136 | const count = html.split("").length - 1; 137 | assert.equal(count >= 3, true, "Expected multiple component wrappers (host)"); 138 | }); 139 | 140 | test("ssr: fragment top-level wrapper", () => { 141 | const html = renderToStringRoot( 142 | h( 143 | Fragment, 144 | null, 145 | h("em", null, "a"), 146 | "b", 147 | h("strong", null, "c"), 148 | ), 149 | ); 150 | notIncludes(html, ""); 151 | notIncludes(html, ""); 152 | includes(html, "a"); 153 | includes(html, "b"); 154 | includes(html, "c"); 155 | }); 156 | 157 | test("ssr: attribute escaping", () => { 158 | const html = renderToStringRoot( 159 | h("div", { title: '<>&"' }, "x"), 160 | ); 161 | includes(html, 'title="<>&""'); 162 | includes(html, ">x
"); 163 | }); 164 | 165 | test("ssr: subscribable one-shot sampling (renders first emission only)", () => { 166 | const html = renderToStringRoot( 167 | h("section", null, multiShot("first", "second")), 168 | ); 169 | includes(html, "
"); 170 | includes(html, "first"); 171 | assert.equal( 172 | html.includes("second"), 173 | false, 174 | "Second emission should not render", 175 | ); 176 | }); 177 | 178 | test("ssr: component error fallback marker", () => { 179 | const html = renderToStringRoot( 180 | h("div", null, h(ErrorComponent, null)), 181 | ); 182 | includes(html, "ERROR:ErrorComponent"); 183 | notIncludes(html, " { 187 | const html = renderToStringRoot( 188 | h(Leaf, { text: "sample", flag: true }), 189 | ); 190 | includes(html, 'data-leaf="sample"'); 191 | includes(html, ""); // flag 192 | includes(html, ""); // explicit null child 193 | // Leaf with flag=true does not emit a false marker 194 | }); 195 | 196 | test("ssr: mixed types & component chain", () => { 197 | // Wrapper now reads its label from props().label (not props().text) 198 | const html = renderToStringRoot( 199 | h( 200 | "main", 201 | null, 202 | h(Wrapper, { label: "mix" }), 203 | oneShot("sub-value"), 204 | h(Leaf, { text: "tail" }), 205 | ), 206 | ); 207 | includes(html, "
"); 208 | includes(html, "mix"); 209 | includes(html, "sub-value"); 210 | includes(html, "tail"); 211 | includes(html, "
"); 212 | }); 213 | 214 | /* -------------------------------------------------------------------------- */ 215 | /* Run */ 216 | /* -------------------------------------------------------------------------- */ 217 | 218 | await test.run(); 219 | -------------------------------------------------------------------------------- /src/tests/style-observable.test.tsx: -------------------------------------------------------------------------------- 1 | import { assert, test } from "@marcisbee/rion/test"; 2 | import { mount } from "../../test/utils.ts"; 3 | import { update } from "../client.ts"; 4 | 5 | type Observable = { 6 | current: T; 7 | subscribe(cb: (v: T) => void): { unsubscribe(): void } | (() => void); 8 | }; 9 | 10 | function makeObservable( 11 | initial: T, 12 | ): Observable & { set(value: T): void } { 13 | let current = initial; 14 | const listeners = new Set<(v: T) => void>(); 15 | 16 | const subscribe = (cb: (v: T) => void) => { 17 | listeners.add(cb); 18 | cb(current); 19 | return () => { 20 | listeners.delete(cb); 21 | }; 22 | }; 23 | 24 | return { 25 | get current() { 26 | return current; 27 | }, 28 | subscribe, 29 | set(value: T) { 30 | current = value; 31 | for (const l of listeners) l(current); 32 | }, 33 | }; 34 | } 35 | 36 | test("style prop: per-property reactive function", async () => { 37 | let color = "red"; 38 | 39 | function App() { 40 | return
color }} />; 41 | } 42 | 43 | const container = await mount(, document.body); 44 | const el = container.querySelector("div") as HTMLDivElement; 45 | 46 | assert.equal(el.style.color, "red"); 47 | 48 | color = "blue"; 49 | update(container); 50 | assert.equal(el.style.color, "blue"); 51 | }); 52 | 53 | test("style prop: per-property observable", async () => { 54 | const color$ = makeObservable("red"); 55 | 56 | function App() { 57 | return
; 58 | } 59 | 60 | const container = await mount(, document.body); 61 | const el = container.querySelector("div") as HTMLDivElement; 62 | 63 | assert.equal(el.style.color, "red"); 64 | 65 | color$.set("green"); 66 | assert.equal(el.style.color, "green"); 67 | 68 | color$.set("blue"); 69 | assert.equal(el.style.color, "blue"); 70 | }); 71 | 72 | test("style prop: whole style object reactive with nested reactive property", async () => { 73 | let color = "red"; 74 | 75 | function App() { 76 | return ( 77 |
({ 79 | color: () => color, 80 | })} 81 | /> 82 | ); 83 | } 84 | 85 | const container = await mount(, document.body); 86 | const el = container.querySelector("div") as HTMLDivElement; 87 | 88 | assert.equal(el.style.color, "red"); 89 | 90 | color = "blue"; 91 | update(container); 92 | assert.equal(el.style.color, "blue"); 93 | }); 94 | 95 | test("style prop: whole style object observable with nested reactive/observable", async () => { 96 | let size = "10px"; 97 | const color$ = makeObservable("red"); 98 | 99 | const style$ = makeObservable({ 100 | color: () => color$.current, 101 | fontSize: () => size, 102 | }); 103 | 104 | function App() { 105 | return
; 106 | } 107 | 108 | const container = await mount(, document.body); 109 | const el = container.querySelector("div") as HTMLDivElement; 110 | 111 | assert.equal(el.style.color, "red"); 112 | assert.equal(el.style.fontSize, "10px"); 113 | 114 | size = "20px"; 115 | update(container); 116 | assert.equal(el.style.fontSize, "20px"); 117 | 118 | color$.set("blue"); 119 | update(container); 120 | assert.equal(el.style.color, "blue"); 121 | }); 122 | 123 | test("style prop: per-property reactive function re-evaluation count", async () => { 124 | let color = "red"; 125 | let evalCount = 0; 126 | 127 | function App() { 128 | return ( 129 |
{ 132 | evalCount++; 133 | return color; 134 | }, 135 | }} 136 | /> 137 | ); 138 | } 139 | 140 | const container = await mount(, document.body); 141 | const el = container.querySelector("div") as HTMLDivElement; 142 | 143 | // initial evaluation during first render 144 | assert.equal(el.style.color, "red"); 145 | assert.equal(evalCount, 1); 146 | 147 | // change backing value and trigger update 148 | color = "blue"; 149 | update(container); 150 | 151 | // should re-evaluate exactly once per update 152 | assert.equal(el.style.color, "blue"); 153 | assert.equal(evalCount, 2); 154 | }); 155 | 156 | test("style prop: per-property observable subscription and update count", async () => { 157 | const color$ = makeObservable("red"); 158 | let subscriptionCalls = 0; 159 | 160 | function App() { 161 | return ( 162 |
void) { 167 | // wrap underlying observable to count how many times 168 | // style subscription callback is invoked 169 | return color$.subscribe((v) => { 170 | subscriptionCalls++; 171 | cb(v); 172 | }); 173 | }, 174 | } as Observable, 175 | }} 176 | /> 177 | ); 178 | } 179 | 180 | const container = await mount(, document.body); 181 | const el = container.querySelector("div") as HTMLDivElement; 182 | 183 | // initial sync 184 | assert.equal(el.style.color, "red"); 185 | assert.equal(subscriptionCalls, 1); 186 | 187 | color$.set("green"); 188 | assert.equal(el.style.color, "green"); 189 | assert.equal(subscriptionCalls, 2); 190 | 191 | color$.set("blue"); 192 | assert.equal(el.style.color, "blue"); 193 | assert.equal(subscriptionCalls, 3); 194 | }); 195 | 196 | test("style prop: whole reactive object with nested reactive property re-evaluation count", async () => { 197 | let color = "red"; 198 | let styleEvalCount = 0; 199 | let nestedEvalCount = 0; 200 | 201 | function App() { 202 | return ( 203 |
{ 205 | styleEvalCount++; 206 | return { 207 | color: () => { 208 | nestedEvalCount++; 209 | return color; 210 | }, 211 | }; 212 | }} 213 | /> 214 | ); 215 | } 216 | 217 | const container = await mount(, document.body); 218 | const el = container.querySelector("div") as HTMLDivElement; 219 | 220 | // first render 221 | assert.equal(el.style.color, "red"); 222 | assert.equal(styleEvalCount, 1); 223 | assert.equal(nestedEvalCount, 1); 224 | 225 | // update backing value and trigger update 226 | color = "blue"; 227 | update(container); 228 | 229 | // implementation may evaluate nested reactive more than once per update 230 | assert.equal(el.style.color, "blue"); 231 | assert.equal(styleEvalCount, 2); 232 | assert.equal(nestedEvalCount, 4); 233 | }); 234 | 235 | await test.run(); 236 | -------------------------------------------------------------------------------- /src/tests/server/parity/ssr-parity-attributes-styles.test.tsx: -------------------------------------------------------------------------------- 1 | import { assert, test } from "@marcisbee/rion/test"; 2 | import { 3 | createElement as serverCreateElement, 4 | renderToStringRoot, 5 | } from "../../../server.ts"; 6 | import { 7 | createElement as clientCreateElement, 8 | createRoot, 9 | } from "../../../client.ts"; 10 | 11 | /** 12 | * Parity edge case tests for attribute & style serialization. 13 | * Goal: Server HTML string output matches client DOM outerHTML for: 14 | * - null vs undefined 15 | * - empty string values 16 | * - boolean attributes (true present, false omitted) 17 | * - numeric and BigInt-like values 18 | * - data-* and aria-* style attributes 19 | * - className normalization and space handling 20 | * - attribute ordering determinism 21 | * - style object: camelCase, vendor prefixes, numeric values, zero, omission of null/undefined/boolean 22 | * - style mixed types (string vs number) 23 | */ 24 | 25 | function snapshot(serverNode: unknown, clientNode: unknown) { 26 | const htmlServer = renderToStringRoot(serverNode as any); 27 | const htmlClient = 28 | createRoot(document.body).render(clientNode as any).outerHTML; 29 | assert.snapshot.html(htmlServer, htmlClient); 30 | } 31 | 32 | test("attributes: null vs undefined omission & literal null", () => { 33 | snapshot( 34 | serverCreateElement("div", { a: null, b: undefined, c: "x" }, "A"), 35 | clientCreateElement("div", { a: null, b: undefined, c: "x" }, "A"), 36 | ); 37 | }); 38 | 39 | test("attributes: empty string value preserved", () => { 40 | const serverNode = serverCreateElement("input", { value: "" }); 41 | const clientNode = clientCreateElement("input", { value: "" }); 42 | const serverHTML = renderToStringRoot(serverNode as any); 43 | const clientHTML = 44 | createRoot(document.body).render(clientNode as any).outerHTML; 45 | // Server should serialize explicit empty value attribute. 46 | assert.equal( 47 | serverHTML.includes('value=""'), 48 | true, 49 | 'Server should include value="" for empty string', 50 | ); 51 | // Client may omit value="" for empty input; if it includes it, enforce full parity snapshot. 52 | if (clientHTML.includes('value=""')) { 53 | assert.snapshot.html(serverHTML, clientHTML); 54 | } else { 55 | // If client omits attribute, accept as parity allowance. 56 | assert.equal( 57 | clientHTML.includes('value=""'), 58 | false, 59 | "Client omitted value attribute (acceptable)", 60 | ); 61 | } 62 | }); 63 | 64 | test("attributes: boolean true present, false omitted", () => { 65 | snapshot( 66 | serverCreateElement("button", { disabled: true, inert: false }, "Btn"), 67 | clientCreateElement("button", { disabled: true, inert: false }, "Btn"), 68 | ); 69 | }); 70 | 71 | test("attributes: numeric values and zero", () => { 72 | snapshot( 73 | serverCreateElement("div", { "data-count": 0, tabIndex: 3 }, "N"), 74 | clientCreateElement("div", { "data-count": 0, tabIndex: 3 }, "N"), 75 | ); 76 | }); 77 | 78 | test("attributes: data-* and aria-* casing", () => { 79 | snapshot( 80 | serverCreateElement("div", { "data-test": "DT", "aria-label": "AL" }, "D"), 81 | clientCreateElement("div", { "data-test": "DT", "aria-label": "AL" }, "D"), 82 | ); 83 | }); 84 | 85 | test("attributes: className normalization & duplicate spaces retained", () => { 86 | snapshot( 87 | serverCreateElement("div", { className: " foo bar baz " }, "C"), 88 | clientCreateElement("div", { className: " foo bar baz " }, "C"), 89 | ); 90 | }); 91 | 92 | test("attributes: ordering determinism with mixed types", () => { 93 | const props = { 94 | id: "root", 95 | className: "order", 96 | "data-x": "1", 97 | title: "<&>", 98 | hidden: true, // use a universally valid boolean attribute for empty-value parity 99 | a: null, 100 | z: "last", 101 | }; 102 | snapshot( 103 | serverCreateElement("div", props, "O"), 104 | clientCreateElement("div", { ...props }, "O"), 105 | ); 106 | }); 107 | 108 | test("styles: camelCase & vendor prefixes & numeric values", () => { 109 | snapshot( 110 | serverCreateElement( 111 | "div", 112 | { 113 | style: { 114 | backgroundColor: "black", 115 | borderTopLeftRadius: "4px", 116 | WebkitLineClamp: 3, 117 | opacity: 0.5, 118 | }, 119 | }, 120 | "S", 121 | ), 122 | clientCreateElement( 123 | "div", 124 | { 125 | style: { 126 | backgroundColor: "black", 127 | borderTopLeftRadius: "4px", 128 | WebkitLineClamp: 3, 129 | opacity: 0.5, 130 | }, 131 | }, 132 | "S", 133 | ), 134 | ); 135 | }); 136 | 137 | test("styles: zero values vs string zeros", () => { 138 | snapshot( 139 | serverCreateElement( 140 | "div", 141 | { 142 | style: { 143 | margin: 0, 144 | padding: "0", 145 | lineHeight: 1, 146 | }, 147 | }, 148 | "Z", 149 | ), 150 | clientCreateElement( 151 | "div", 152 | { 153 | style: { 154 | margin: 0, 155 | padding: "0", 156 | lineHeight: 1, 157 | }, 158 | }, 159 | "Z", 160 | ), 161 | ); 162 | }); 163 | 164 | test("styles: omission of null/undefined/boolean keys", () => { 165 | snapshot( 166 | serverCreateElement( 167 | "div", 168 | { 169 | style: { 170 | color: "red", 171 | padding: null, 172 | margin: undefined, 173 | outline: false, 174 | fontSize: 14, 175 | }, 176 | }, 177 | "O", 178 | ), 179 | clientCreateElement( 180 | "div", 181 | { 182 | style: { 183 | color: "red", 184 | padding: null, 185 | margin: undefined, 186 | outline: false, 187 | fontSize: 14, 188 | }, 189 | }, 190 | "O", 191 | ), 192 | ); 193 | }); 194 | 195 | test("styles: mixed string & number consistency", () => { 196 | snapshot( 197 | serverCreateElement( 198 | "div", 199 | { 200 | style: { 201 | width: 10, 202 | height: "20px", 203 | flexGrow: 1, 204 | zIndex: 2, 205 | }, 206 | }, 207 | "M", 208 | ), 209 | clientCreateElement( 210 | "div", 211 | { 212 | style: { 213 | width: 10, 214 | height: "20px", 215 | flexGrow: 1, 216 | zIndex: 2, 217 | }, 218 | }, 219 | "M", 220 | ), 221 | ); 222 | }); 223 | 224 | test("styles: empty style object yields no style attribute", () => { 225 | snapshot( 226 | serverCreateElement("div", { style: {} }, "E"), 227 | clientCreateElement("div", { style: {} }, "E"), 228 | ); 229 | }); 230 | 231 | await test.run(); 232 | -------------------------------------------------------------------------------- /src/tests/non-keyed-table-large.test.tsx: -------------------------------------------------------------------------------- 1 | import { assert, test } from "@marcisbee/rion/test"; 2 | import { mount } from "../../test/utils.ts"; 3 | import { update } from "../client.ts"; 4 | 5 | /** 6 | * Row type for table entries. 7 | */ 8 | interface Row { 9 | id: number; 10 | label: string; 11 | selected: boolean; 12 | } 13 | 14 | /** 15 | * Data sources for random label construction. 16 | */ 17 | const adjectives = [ 18 | "pretty", 19 | "large", 20 | "big", 21 | "small", 22 | "tall", 23 | "short", 24 | "long", 25 | "handsome", 26 | "plain", 27 | "quaint", 28 | "clean", 29 | "elegant", 30 | "easy", 31 | "angry", 32 | "crazy", 33 | "helpful", 34 | "mushy", 35 | "odd", 36 | "unsightly", 37 | "adorable", 38 | "important", 39 | "inexpensive", 40 | "cheap", 41 | "expensive", 42 | "fancy", 43 | ]; 44 | const colours = [ 45 | "red", 46 | "yellow", 47 | "blue", 48 | "green", 49 | "pink", 50 | "brown", 51 | "purple", 52 | "brown", 53 | "white", 54 | "black", 55 | "orange", 56 | ]; 57 | const nouns = [ 58 | "table", 59 | "chair", 60 | "house", 61 | "bbq", 62 | "desk", 63 | "car", 64 | "pony", 65 | "cookie", 66 | "sandwich", 67 | "burger", 68 | "pizza", 69 | "mouse", 70 | "keyboard", 71 | ]; 72 | 73 | function rand(max: number): number { 74 | return Math.round(Math.random() * 1000) % max; 75 | } 76 | 77 | /** 78 | * Build count rows, incrementing a global id. 79 | */ 80 | let nextId = 1; 81 | function buildData(count: number): Row[] { 82 | const out: Row[] = []; 83 | for (let i = 0; i < count; i++) { 84 | out.push({ 85 | id: nextId++, 86 | label: `${adjectives[rand(adjectives.length)]} ${ 87 | colours[rand(colours.length)] 88 | } ${nouns[rand(nouns.length)]}`, 89 | selected: false, 90 | }); 91 | } 92 | return out; 93 | } 94 | 95 | /** 96 | * Large non-keyed table demo component replicating playground scenario. 97 | * - Generates 1000 rows 98 | * - Appends 1000 rows 99 | * - Regenerates (fresh 1000 with new ids) 100 | */ 101 | function NonKeyedLargeTableRoot(this: HTMLElement) { 102 | let rows: Row[] = []; 103 | 104 | const generate = () => { 105 | rows = buildData(100); 106 | update(this); 107 | }; 108 | 109 | const append = () => { 110 | rows = rows.concat(buildData(100)); 111 | update(this); 112 | }; 113 | 114 | const swapRows = () => { 115 | if (rows.length > 98) { 116 | const tmp = rows[1]; 117 | rows[1] = rows[98]; 118 | rows[98] = tmp; 119 | } 120 | update(this); 121 | }; 122 | 123 | const updateRows = () => { 124 | for (let i = 0; i < rows.length; i += 10) { 125 | rows[i].label += " !!!"; 126 | } 127 | update(this); 128 | }; 129 | 130 | (this as any).__swapRows = swapRows; 131 | (this as any).__updateRows = updateRows; 132 | 133 | return () => ( 134 |
135 |
136 | 145 | 154 | 163 |
164 | 165 | 166 | {rows.map((r) => ( 167 | 171 | 172 | 173 | 174 | ))} 175 | 176 |
{r.id}{r.label}
177 | {rows.length} 178 | {nextId} 179 |
180 | ); 181 | } 182 | 183 | /** 184 | * Assert a single row element has expected id and non-empty label. 185 | */ 186 | function assertRow(el: HTMLTableRowElement, expectedId: number) { 187 | assert.equal(parseInt(el.id, 10), expectedId); 188 | const labelCell = el.querySelector(".col-label") as HTMLElement; 189 | assert.true( 190 | labelCell && labelCell.textContent && labelCell.textContent.length > 0, 191 | ); 192 | } 193 | 194 | /** 195 | * Regression test + diagnostics: non-keyed large table should render full 1000 rows. 196 | * Adds console diagnostics for child counts and sample node details. 197 | */ 198 | test("large-non-keyed-table-renders-all-and-appends", async () => { 199 | const root = await mount(, document.body); 200 | 201 | const generateBtn = root.querySelector(".btn-generate") as HTMLButtonElement; 202 | const appendBtn = root.querySelector(".btn-append") as HTMLButtonElement; 203 | const regenerateBtn = root.querySelector( 204 | ".btn-regenerate", 205 | ) as HTMLButtonElement; 206 | const tbody = root.querySelector("tbody")!; 207 | 208 | // Initial: no rows 209 | assert.equal(tbody.children.length, 0); 210 | 211 | // Generate 1000 212 | generateBtn.click(); 213 | await Promise.resolve(); 214 | assert.equal(root.querySelector("tbody")!.children.length, 100); 215 | 216 | const originalRow2 = root.querySelector("tbody")!.children[1] as HTMLElement; 217 | const originalRow999 = root.querySelector("tbody")! 218 | .children[98] as HTMLElement; 219 | const originalId2 = originalRow2.id; 220 | const originalId999 = originalRow999.id; 221 | 222 | (root as any).__swapRows(); 223 | await Promise.resolve(); 224 | 225 | const newRow2 = root.querySelector("tbody")!.children[1] as HTMLElement; 226 | const newRow999 = root.querySelector("tbody")!.children[98] as HTMLElement; 227 | assert.equal(newRow2.id, originalId999); 228 | assert.equal(newRow999.id, originalId2); 229 | }); 230 | 231 | test("update-rows", async () => { 232 | const root = await mount(, document.body); 233 | 234 | const generateBtn = root.querySelector(".btn-generate") as HTMLButtonElement; 235 | generateBtn.click(); 236 | await Promise.resolve(); 237 | assert.equal(root.querySelector("tbody")!.children.length, 100); 238 | 239 | const row0 = root.querySelector("tbody")!.children[0] as HTMLElement; 240 | const originalLabel = row0.querySelector(".col-label")!.textContent; 241 | 242 | (root as any).__updateRows(); 243 | await Promise.resolve(); 244 | 245 | const newLabel = row0.querySelector(".col-label")!.textContent; 246 | assert.equal(newLabel, originalLabel + " !!!"); 247 | }); 248 | 249 | await test.run(); 250 | -------------------------------------------------------------------------------- /src/tests/server/parity/ssr-parity-probe.test.tsx: -------------------------------------------------------------------------------- 1 | import { assert, test } from "@marcisbee/rion/test"; 2 | import { createElement as h, createRoot } from "../../../client.ts"; 3 | 4 | /** 5 | * Empirical DOM parity probe. 6 | * 7 | * Captures actual client-side outerHTML and attribute/style state for edge cases. 8 | * Output is logged as JSON lines (one per probe). Assertions are minimal to avoid 9 | * premature failures before server parity adjustments are finalized. 10 | * 11 | * To view raw results, run only this test file: 12 | * deno task test src/tests/server/parity/ssr-parity-probe.test.tsx 13 | * 14 | * Each test logs: 15 | * { 16 | * id, 17 | * outerHTML, 18 | * attributes: { name: value }, 19 | * props: { disabled, tabIndex, className, id, value }, 20 | * style: { cssText, props: { k: v } } 21 | * } 22 | */ 23 | 24 | interface ProbeResult { 25 | id: string; 26 | outerHTML: string; 27 | attributes: Record; 28 | props: Record; 29 | style: { 30 | cssText: string; 31 | props: Record; 32 | }; 33 | } 34 | 35 | function collectAttributes(el: Element): Record { 36 | const out: Record = {}; 37 | for (const attr of Array.from(el.attributes)) { 38 | out[attr.name] = attr.value; 39 | } 40 | return out; 41 | } 42 | 43 | function collectStyle( 44 | el: HTMLElement, 45 | ): { cssText: string; props: Record } { 46 | const out: Record = {}; 47 | const cssText = el.style.cssText; 48 | if (cssText) { 49 | for (const seg of cssText.split(";")) { 50 | const trimmed = seg.trim(); 51 | if (!trimmed) continue; 52 | const [k, v] = trimmed.split(":"); 53 | if (k && v != null) { 54 | out[k.trim()] = v.trim(); 55 | } 56 | } 57 | } 58 | return { cssText, props: out }; 59 | } 60 | 61 | function mount(node: any): HTMLElement { 62 | const container = document.createElement("div"); 63 | const root = createRoot(container); 64 | const rendered = root.render(node); 65 | return rendered instanceof HTMLElement 66 | ? rendered 67 | : container.firstElementChild as HTMLElement; 68 | } 69 | 70 | function probe(id: string, nodeFactory: () => any): ProbeResult { 71 | const el = mount(nodeFactory()); 72 | const attributes = el ? collectAttributes(el) : {}; 73 | const styleInfo = el instanceof HTMLElement 74 | ? collectStyle(el) 75 | : { cssText: "", props: {} }; 76 | const props: Record = {}; 77 | if (el instanceof HTMLElement) { 78 | props.disabled = (el as HTMLButtonElement).disabled; 79 | props.tabIndex = el.tabIndex; 80 | props.className = el.className; 81 | props.id = el.id; 82 | if ("value" in el) { 83 | props.value = (el as HTMLInputElement).value; 84 | } 85 | } 86 | return { 87 | id, 88 | outerHTML: el?.outerHTML || "", 89 | attributes, 90 | props, 91 | style: styleInfo, 92 | }; 93 | } 94 | 95 | function logResult(r: ProbeResult): void { 96 | // JSON line 97 | // console.log(JSON.stringify(r)); 98 | } 99 | 100 | /* -------------------------------------------------------------------------- */ 101 | /* Probe Cases */ 102 | /* -------------------------------------------------------------------------- */ 103 | 104 | const cases: Array<[string, () => any]> = [ 105 | [ 106 | "attributes-null-undefined", 107 | () => h("div", { a: null, b: undefined, c: "x" }, "A"), 108 | ], 109 | ["input-empty-string", () => h("input", { value: "" }, null)], 110 | [ 111 | "boolean-attributes", 112 | () => h("button", { disabled: true, inert: false }, "Btn"), 113 | ], 114 | [ 115 | "numeric-and-tabindex", 116 | () => h("div", { "data-count": 0, tabIndex: 3 }, "N"), 117 | ], 118 | ["mixed-ordering", () => 119 | h("div", { 120 | id: "root", 121 | className: "order", 122 | "data-x": "1", 123 | title: "<&>", 124 | disabled: true, 125 | a: null, 126 | z: "last", 127 | }, "O")], 128 | ["styles-camel-vendor-numeric", () => 129 | h("div", { 130 | style: { 131 | backgroundColor: "black", 132 | borderTopLeftRadius: "4px", 133 | WebkitLineClamp: 3, 134 | opacity: 0.5, 135 | }, 136 | }, "S")], 137 | ["styles-zero-values", () => 138 | h("div", { 139 | style: { 140 | margin: 0, 141 | padding: "0", 142 | lineHeight: 1, 143 | }, 144 | }, "Z")], 145 | ["styles-omit-invalid", () => 146 | h("div", { 147 | style: { 148 | color: "red", 149 | padding: null, 150 | margin: undefined, 151 | outline: false, 152 | fontSize: 14, 153 | }, 154 | }, "O")], 155 | ["styles-mixed-lengths", () => 156 | h("div", { 157 | style: { 158 | width: 10, 159 | height: "20px", 160 | flexGrow: 1, 161 | zIndex: 2, 162 | }, 163 | }, "M")], 164 | ["styles-empty-object", () => h("div", { style: {} }, "E")], 165 | // Direct style mutation (non-Radi style application) 166 | ["styles-manual-fontSize-number", () => { 167 | const el = document.createElement("div"); 168 | el.textContent = "FS"; 169 | (el.style as any).fontSize = 14; 170 | return el; 171 | }], 172 | ["styles-manual-fontSize-px", () => { 173 | const el = document.createElement("div"); 174 | el.textContent = "FSPX"; 175 | el.style.fontSize = "14px"; 176 | return el; 177 | }], 178 | [ 179 | "attributes-boolean-false-only", 180 | () => h("input", { disabled: false }, null), 181 | ], 182 | ["attributes-data-array", () => h("div", { "data-arr": [1, 2, 3] }, "ARR")], 183 | ["attributes-bigint-like", () => h("div", { "data-big": BigInt(10) }, "BIG")], 184 | // ["attributes-symbol-function", () => { 185 | // const sym = Symbol("s"); 186 | // return h("div", { sym, fn: () => "ignored", keep: "yes" }, "SF"); 187 | // }], 188 | ]; 189 | 190 | /* -------------------------------------------------------------------------- */ 191 | /* Tests */ 192 | /* -------------------------------------------------------------------------- */ 193 | 194 | for (const [id, factory] of cases) { 195 | test(`probe: ${id}`, () => { 196 | const result = probe(id, factory); 197 | // Minimal assertions: outerHTML should exist; attribute omission rules are empirical. 198 | assert.equal( 199 | result.outerHTML.length > 0, 200 | true, 201 | `${id} outerHTML should not be empty`, 202 | ); 203 | logResult(result); 204 | }); 205 | } 206 | 207 | /* -------------------------------------------------------------------------- */ 208 | /* Run */ 209 | /* -------------------------------------------------------------------------- */ 210 | await test.run(); 211 | -------------------------------------------------------------------------------- /src/tests/error-handling.test.tsx: -------------------------------------------------------------------------------- 1 | import { update } from "../client.ts"; 2 | import { assert, test } from "@marcisbee/rion/test"; 3 | import { mount } from "../../test/utils.ts"; 4 | 5 | /** 6 | * Utility to create + attach a container root. 7 | */ 8 | function createContainer(): HTMLElement { 9 | const div = document.createElement("div"); 10 | document.body.appendChild(div); 11 | return div; 12 | } 13 | 14 | test("catches child component render error via parent listener", async () => { 15 | let caught: unknown = null; 16 | let callCount = 0; 17 | 18 | function Child(): never { 19 | throw new Error("child boom"); 20 | } 21 | 22 | function Parent(this: HTMLElement) { 23 | this.addEventListener( 24 | "error", 25 | (e: Event) => { 26 | e.preventDefault(); 27 | const ce = e as ErrorEvent; 28 | callCount++; 29 | caught = ce.error; 30 | e.stopImmediatePropagation(); 31 | }, 32 | { once: true }, 33 | ); 34 | return ; 35 | } 36 | 37 | const container = createContainer(); 38 | await mount( as unknown as HTMLElement, container); 39 | 40 | assert.equal(callCount, 1); 41 | assert.instanceOf(caught, Error); 42 | assert.equal((caught as Error).message, "child boom"); 43 | }); 44 | 45 | test("uncaught component error bubbles to container listener", async () => { 46 | let bubbleCaught: unknown = null; 47 | 48 | function Boom(): never { 49 | throw new Error("boom root"); 50 | } 51 | 52 | const container = createContainer(); 53 | container.addEventListener( 54 | "error", 55 | (e: Event) => { 56 | const ce = e as ErrorEvent; 57 | bubbleCaught = ce.error; 58 | e.preventDefault(); 59 | }, 60 | { once: true }, 61 | ); 62 | 63 | await mount( as unknown as HTMLElement, container); 64 | 65 | assert.instanceOf(bubbleCaught, Error); 66 | assert.equal((bubbleCaught as Error).message, "boom root"); 67 | }); 68 | 69 | test("reactive generator error bubbles", async () => { 70 | let caught: unknown = null; 71 | 72 | function ReactiveThrower(this: HTMLElement): () => never { 73 | this.addEventListener( 74 | "error", 75 | (e: Event) => { 76 | e.preventDefault(); 77 | caught = (e as ErrorEvent).error; 78 | e.stopPropagation(); 79 | }, 80 | { once: true }, 81 | ); 82 | return () => { 83 | throw new Error("reactive boom"); 84 | }; 85 | } 86 | 87 | const container = createContainer(); 88 | await mount( as unknown as HTMLElement, container); 89 | 90 | assert.instanceOf(caught, Error); 91 | assert.equal((caught as Error).message, "reactive boom"); 92 | }); 93 | 94 | test("prop function evaluation error emits error event", async () => { 95 | let caught: unknown = null; 96 | 97 | function Holder(this: HTMLElement): JSX.Element { 98 | this.addEventListener( 99 | "error", 100 | (e: Event) => { 101 | e.preventDefault(); 102 | caught = (e as ErrorEvent).error; 103 | e.stopImmediatePropagation(); 104 | }, 105 | { once: true }, 106 | ); 107 | return ( 108 |
{ 110 | throw new Error("prop eval"); 111 | }} 112 | /> 113 | ); 114 | } 115 | 116 | const container = createContainer(); 117 | await mount( as unknown as HTMLElement, container); 118 | 119 | assert.instanceOf(caught, Error); 120 | assert.equal((caught as Error).message, "prop eval"); 121 | }); 122 | 123 | test("prop function throws on update dispatch", async () => { 124 | let caught: unknown = null; 125 | let calls = 0; 126 | 127 | function Updater(this: HTMLElement): JSX.Element { 128 | this.addEventListener( 129 | "error", 130 | (e: Event) => { 131 | e.preventDefault(); 132 | caught = (e as ErrorEvent).error; 133 | e.stopImmediatePropagation(); 134 | }, 135 | { once: true }, 136 | ); 137 | return ( 138 |
{ 140 | calls++; 141 | if (calls === 2) { 142 | throw new Error("update eval"); 143 | } 144 | return "ok"; 145 | }} 146 | /> 147 | ); 148 | } 149 | 150 | const container = createContainer(); 151 | const node = as unknown as HTMLElement; 152 | await mount(node, container); 153 | 154 | assert.equal(calls, 1); 155 | assert.equal(caught, null); 156 | 157 | update(container); 158 | 159 | assert.equal(calls, 2); 160 | assert.instanceOf(caught, Error); 161 | assert.equal((caught as Error).message, "update eval"); 162 | }); 163 | 164 | function ErrorBoundary( 165 | this: HTMLElement, 166 | props: JSX.PropsWithChildren<{ fallback: (err: Error) => JSX.Element }>, 167 | ) { 168 | let error: Error | null = null; 169 | 170 | this.addEventListener( 171 | "error", 172 | (e: Event) => { 173 | e.preventDefault(); 174 | e.stopPropagation(); 175 | 176 | const ce = e as ErrorEvent; 177 | error = ce?.error ?? null; 178 | update(this); 179 | }, 180 | ); 181 | 182 | return () => { 183 | if (error) { 184 | return props().fallback(error); 185 | } 186 | 187 | return props().children; 188 | }; 189 | } 190 | 191 | test("ErrorBoundary renders fallback and prevents error from bubbling", async () => { 192 | let containerCaught: unknown = null; 193 | 194 | function Bomb(): never { 195 | throw new Error("bomb!"); 196 | } 197 | 198 | const container = createContainer(); 199 | container.addEventListener( 200 | "error", 201 | (e: Event) => { 202 | const ce = e as ErrorEvent; 203 | containerCaught = ce.error; 204 | e.preventDefault(); 205 | }, 206 | { once: true }, 207 | ); 208 | 209 | const node = ( 210 |
fallback
}> 211 | 212 |
213 | ) as unknown as HTMLElement; 214 | 215 | await mount(node, container); 216 | 217 | // fallback should be rendered 218 | const fb = container.querySelector("#fb"); 219 | assert.instanceOf(fb, HTMLElement); 220 | assert.equal(fb!.textContent, "fallback"); 221 | 222 | // error should not bubble to container because it's handled 223 | assert.equal(containerCaught, null); 224 | }); 225 | 226 | test("ErrorBoundary passes error into fallback render prop", async () => { 227 | let receivedText = ""; 228 | 229 | function Boom(): never { 230 | throw new Error("boomprop"); 231 | } 232 | 233 | const container = createContainer(); 234 | const node = ( 235 | { 237 | receivedText = err.message; 238 | return
ok
; 239 | }} 240 | > 241 | 242 |
243 | ) as unknown as HTMLElement; 244 | 245 | await mount(node, container); 246 | 247 | // fallback component should receive the error message 248 | assert.equal(receivedText, "boomprop"); 249 | const fb = container.querySelector("#fb-prop"); 250 | assert.instanceOf(fb, HTMLElement); 251 | }); 252 | 253 | await test.run(); 254 | -------------------------------------------------------------------------------- /playground/async.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | createElement, 3 | createRoot, 4 | Fragment, 5 | suspend, 6 | Suspense, 7 | unsuspend, 8 | update, 9 | } from "../src/client.ts"; 10 | 11 | /** 12 | * Example demonstrating async components in Radi 13 | * 14 | * This example shows how to create and use async components that return Promises. 15 | * Async components automatically integrate with the Suspense boundary system. 16 | */ 17 | 18 | // Simple async component that resolves immediately 19 | function ImmediateAsyncComponent() { 20 | return Promise.resolve( 21 |
28 |

Immediate Async Component

29 |

This component resolved immediately

30 |
, 31 | ); 32 | } 33 | 34 | // Async component that fetches data 35 | function UserProfile(props: JSX.Props<{ userId: number }>) { 36 | // Simulate an API call 37 | return new Promise<{ name: string; email: string }>((resolve) => { 38 | console.log("A"); 39 | setTimeout(() => { 40 | console.log("B"); 41 | resolve({ 42 | name: `User ${props().userId}`, 43 | email: `user${props().userId}@example.com`, 44 | }); 45 | }, 1_000 * props().userId); // Delay based on userId 46 | }).then((user) => ( 47 |
54 |

User Profile

55 |

Name: {user.name}

56 |

Email: {user.email}

57 |
58 | )); 59 | } 60 | 61 | // Async component that might fail 62 | function UnreliableComponent(props: JSX.Props<{ shouldFail: boolean }>) { 63 | if (props().shouldFail) { 64 | return Promise.reject(new Error("Failed to load component")); 65 | } 66 | 67 | return Promise.resolve( 68 |
75 |

Unreliable Component

76 |

This component loaded successfully!

77 |
, 78 | ); 79 | } 80 | 81 | function PassThru(props: JSX.Props<{ children: JSX.Element }>) { 82 | return props().children; 83 | } 84 | 85 | function Controller(this: HTMLElement) { 86 | let firstCount = 0; 87 | let resolver = {}; 88 | let rejecter = {}; 89 | 90 | function FirstAsyncComponent() { 91 | // console.log("RENDER?"); 92 | // suspend(this); 93 | // resolver.first = () => { 94 | // // resolve( 95 | // //
102 | // //

First Async Component

103 | // //

Load count: {firstCount}

104 | // //
, 105 | // // ); 106 | // unsuspend(this); 107 | // }; 108 | // return
poop
; 109 | 110 | return new Promise((resolve, reject) => { 111 | console.log(1, resolver); 112 | resolver.first = () => { 113 | resolve( 114 |
121 |

First Async Component

122 |

Load count: {firstCount}

123 |
, 124 | ); 125 | }; 126 | 127 | rejecter.first = () => { 128 | reject(new Error("First Async Component failed to load")); 129 | }; 130 | }); 131 | } 132 | 133 | function Static() { 134 | return ( 135 |
136 | I'm static content. 137 |
138 | ); 139 | } 140 | 141 | return ( 142 |
143 |

Async Components Demo

144 | 145 | 154 | 162 | 170 | 171 |
172 | 173 |
Loading immediate component...
}> 174 | {/*
*/} 175 | 176 | {/*
*/} 177 |
178 | 179 | 180 | 181 | 182 | 183 |
184 | ); 185 | } 186 | 187 | // Main app component 188 | function App() { 189 | let userId = 1; 190 | let shouldFail = false; 191 | 192 | return ( 193 |
201 | 202 | 203 |
204 | 205 |

Async Components Example

206 | 207 |
208 | 216 | 217 | 226 |
227 | 228 |
Loading immediate component...
}> 229 | {() => } 230 |
231 | 232 |

Immediate Async Component

233 |
Loading immediate component...
}> 234 | 235 |
236 | 237 |

User Profile (Async with Delay)

238 |
Loading user profile...
}> 239 | {() => } 240 |
241 | 242 |

Unreliable Component

243 |
Attempting to load component...
}> 244 | {() => } 245 |
246 | 247 |

Mixed Components

248 |
Loading mixed components...
}> 249 | 250 | {() => } 251 | {() => } 252 |
253 |
254 | ); 255 | } 256 | 257 | // Mount the app 258 | createRoot(document.body).render(); 259 | -------------------------------------------------------------------------------- /src/tests/keyed-table-large.test.tsx: -------------------------------------------------------------------------------- 1 | import { assert, test } from "@marcisbee/rion/test"; 2 | import { mount } from "../../test/utils.ts"; 3 | import { createElement, createList, update } from "../client.ts"; 4 | 5 | /** 6 | * Row type for table entries. 7 | */ 8 | interface Row { 9 | id: number; 10 | label: string; 11 | selected: boolean; 12 | } 13 | 14 | /** 15 | * Data sources for random label construction. 16 | */ 17 | const adjectives = [ 18 | "pretty", 19 | "large", 20 | "big", 21 | "small", 22 | "tall", 23 | "short", 24 | "long", 25 | "handsome", 26 | "plain", 27 | "quaint", 28 | "clean", 29 | "elegant", 30 | "easy", 31 | "angry", 32 | "crazy", 33 | "helpful", 34 | "mushy", 35 | "odd", 36 | "unsightly", 37 | "adorable", 38 | "important", 39 | "inexpensive", 40 | "cheap", 41 | "expensive", 42 | "fancy", 43 | ]; 44 | const colours = [ 45 | "red", 46 | "yellow", 47 | "blue", 48 | "green", 49 | "pink", 50 | "brown", 51 | "purple", 52 | "brown", 53 | "white", 54 | "black", 55 | "orange", 56 | ]; 57 | const nouns = [ 58 | "table", 59 | "chair", 60 | "house", 61 | "bbq", 62 | "desk", 63 | "car", 64 | "pony", 65 | "cookie", 66 | "sandwich", 67 | "burger", 68 | "pizza", 69 | "mouse", 70 | "keyboard", 71 | ]; 72 | 73 | function rand(max: number): number { 74 | return Math.round(Math.random() * 1000) % max; 75 | } 76 | 77 | /** 78 | * Build count rows, incrementing a global id. 79 | */ 80 | let nextId = 1; 81 | function buildData(count: number): Row[] { 82 | const out: Row[] = []; 83 | for (let i = 0; i < count; i++) { 84 | out.push({ 85 | id: nextId++, 86 | label: `${adjectives[rand(adjectives.length)]} ${ 87 | colours[rand(colours.length)] 88 | } ${nouns[rand(nouns.length)]}`, 89 | selected: false, 90 | }); 91 | } 92 | return out; 93 | } 94 | 95 | /** 96 | * Large keyed table demo component replicating playground scenario. 97 | * - Generates 1000 rows 98 | * - Appends 1000 rows 99 | * - Regenerates (fresh 1000 with new ids) 100 | */ 101 | function KeyedLargeTableRoot(this: HTMLElement) { 102 | let rows: Row[] = []; 103 | 104 | const generate = () => { 105 | rows = buildData(100); 106 | update(this); 107 | }; 108 | 109 | const append = () => { 110 | rows = rows.concat(buildData(100)); 111 | update(this); 112 | }; 113 | 114 | const swapRows = () => { 115 | if (rows.length > 98) { 116 | const tmp = rows[1]; 117 | rows[1] = rows[98]; 118 | rows[98] = tmp; 119 | } 120 | update(this); 121 | }; 122 | 123 | const updateRows = () => { 124 | for (let i = 0; i < rows.length; i += 10) { 125 | rows[i].label += " !!!"; 126 | } 127 | update(this); 128 | }; 129 | 130 | (this as any).__swapRows = swapRows; 131 | (this as any).__updateRows = updateRows; 132 | 133 | return () => ( 134 |
135 |
136 | 145 | 154 | 163 |
164 | 165 | 166 | {() => 167 | createList((key) => 168 | rows.map((r) => 169 | key( 170 | () => ( 171 | 175 | 176 | 177 | 178 | ), 179 | String(r.id), 180 | ) 181 | ) 182 | )} 183 | 184 |
{r.id}{r.label}
185 | {rows.length} 186 | {nextId} 187 |
188 | ); 189 | } 190 | 191 | /** 192 | * Assert a single row element has expected id and non-empty label. 193 | */ 194 | function assertRow(el: HTMLTableRowElement, expectedId: number) { 195 | assert.equal(parseInt(el.id, 10), expectedId); 196 | const labelCell = el.querySelector(".col-label") as HTMLElement; 197 | assert.true( 198 | labelCell && labelCell.textContent && labelCell.textContent.length > 0, 199 | ); 200 | } 201 | 202 | /** 203 | * Regression test + diagnostics: keyed large table should render full 1000 rows. 204 | * Adds console diagnostics for child counts and sample node details. 205 | */ 206 | test("large-keyed-table-renders-all-and-appends", async () => { 207 | const root = await mount(, document.body); 208 | 209 | const generateBtn = root.querySelector(".btn-generate") as HTMLButtonElement; 210 | const appendBtn = root.querySelector(".btn-append") as HTMLButtonElement; 211 | const regenerateBtn = root.querySelector( 212 | ".btn-regenerate", 213 | ) as HTMLButtonElement; 214 | const tbody = root.querySelector("tbody")!; 215 | 216 | // Initial: no rows 217 | assert.length(tbody.children, 0); 218 | 219 | // Generate 1000 220 | generateBtn.click(); 221 | await Promise.resolve(); 222 | assert.length(root.querySelector("tbody")!.children, 100); 223 | 224 | const originalRow2 = root.querySelector("tbody")!.children[1] as HTMLElement; 225 | const originalRow999 = root.querySelector("tbody")! 226 | .children[98] as HTMLElement; 227 | const originalId2 = originalRow2.id; 228 | const originalId999 = originalRow999.id; 229 | 230 | (root as any).__swapRows(); 231 | await Promise.resolve(); 232 | 233 | const newRow2 = root.querySelector("tbody")!.children[1] as HTMLElement; 234 | const newRow999 = root.querySelector("tbody")!.children[98] as HTMLElement; 235 | assert.equal(newRow2.id, originalId999); 236 | assert.equal(newRow999.id, originalId2); 237 | }); 238 | 239 | test("update-rows", async () => { 240 | const root = await mount(, document.body); 241 | 242 | const generateBtn = root.querySelector(".btn-generate") as HTMLButtonElement; 243 | generateBtn.click(); 244 | await Promise.resolve(); 245 | assert.length(root.querySelector("tbody")!.children, 100); 246 | 247 | const row0 = root.querySelector("tbody")!.children[0] as HTMLElement; 248 | const originalLabel = row0.querySelector(".col-label")!.textContent; 249 | 250 | (root as any).__updateRows(); 251 | await Promise.resolve(); 252 | 253 | const newLabel = row0.querySelector(".col-label")!.textContent; 254 | assert.equal(newLabel, originalLabel + " !!!"); 255 | }); 256 | 257 | await test.run(); 258 | -------------------------------------------------------------------------------- /src/tests/create-list.test.tsx: -------------------------------------------------------------------------------- 1 | import { assert, test } from "@marcisbee/rion/test"; 2 | import { mount } from "../../test/utils.ts"; 3 | import { createList, update } from "../client.ts"; 4 | 5 | test("sanity", async () => { 6 | function App(this: HTMLElement) { 7 | return
Hello
; 8 | } 9 | 10 | const container = await mount(, document.body); 11 | assert.equal(container.textContent, "Hello"); 12 | }); 13 | 14 | test("createList: renders simple keyed list", async () => { 15 | const items = [ 16 | { id: 1, text: "Item 1" }, 17 | { id: 2, text: "Item 2" }, 18 | { id: 3, text: "Item 3" }, 19 | ]; 20 | 21 | function App(this: HTMLElement) { 22 | return ( 23 |
    24 | {() => 25 | createList((key) => 26 | items.map((item) => key(() =>
  • {item.text}
  • , item.id)) 27 | )} 28 |
29 | ); 30 | } 31 | 32 | const container = await mount(, document.body); 33 | 34 | const listItems = container.querySelectorAll("li"); 35 | assert.equal(listItems.length, 3); 36 | assert.equal(listItems[0].textContent, "Item 1"); 37 | assert.equal(listItems[1].textContent, "Item 2"); 38 | assert.equal(listItems[2].textContent, "Item 3"); 39 | }); 40 | 41 | test("createList: starts with 0", async () => { 42 | let items: any[] = []; 43 | 44 | function App(this: HTMLElement) { 45 | return ( 46 |
    47 | {() => 48 | createList((key) => 49 | items.map((item) => key(() =>
  • {item.text}
  • , item.id)) 50 | )} 51 |
52 | ); 53 | } 54 | 55 | const container = await mount(, document.body); 56 | 57 | const firstRenderNodes = Array.from(container.querySelectorAll("li")); 58 | 59 | items = [ 60 | { id: 2, text: "Item 2 Updated" }, 61 | { id: 1, text: "Item 1 Updated" }, 62 | ]; 63 | 64 | update(container); 65 | 66 | const secondRenderNodes = Array.from(container.querySelectorAll("li")); 67 | 68 | assert.equal(firstRenderNodes.length, 0); 69 | assert.equal(secondRenderNodes.length, 2); 70 | }); 71 | 72 | test("createList: ends with 0", async () => { 73 | let items = [ 74 | { id: 1, text: "Item 1" }, 75 | { id: 2, text: "Item 2" }, 76 | ]; 77 | 78 | function App(this: HTMLElement) { 79 | return ( 80 |
    81 | {() => 82 | createList((key) => 83 | items.map((item) => key(() =>
  • {item.text}
  • , item.id)) 84 | )} 85 |
86 | ); 87 | } 88 | 89 | const container = await mount(, document.body); 90 | 91 | const firstRenderNodes = Array.from(container.querySelectorAll("li")); 92 | 93 | items = []; 94 | 95 | update(container); 96 | 97 | const secondRenderNodes = Array.from(container.querySelectorAll("li")); 98 | 99 | assert.equal(firstRenderNodes.length, 2); 100 | assert.equal(secondRenderNodes.length, 0); 101 | }); 102 | 103 | test("createList: reuses nodes when keys match", async () => { 104 | let items = [ 105 | { id: 1, text: "Item 1" }, 106 | { id: 2, text: "Item 2" }, 107 | ]; 108 | 109 | function App(this: HTMLElement) { 110 | return ( 111 |
    112 | {() => 113 | createList((key) => 114 | items.map((item) => key(() =>
  • {item.text}
  • , item.id)) 115 | )} 116 |
117 | ); 118 | } 119 | 120 | const container = await mount(, document.body); 121 | 122 | const firstRenderNodes = Array.from(container.querySelectorAll("li")); 123 | 124 | items = [ 125 | { id: 2, text: "Item 2 Updated" }, 126 | { id: 1, text: "Item 1 Updated" }, 127 | ]; 128 | 129 | update(container); 130 | 131 | const secondRenderNodes = Array.from(container.querySelectorAll("li")); 132 | 133 | assert.equal(secondRenderNodes.length, 2); 134 | assert.equal(firstRenderNodes[0], secondRenderNodes[1]); 135 | assert.equal(firstRenderNodes[1], secondRenderNodes[0]); 136 | }); 137 | 138 | test("createList: adds new items with new keys", async () => { 139 | let items = [ 140 | { id: 1, text: "Item 1" }, 141 | { id: 2, text: "Item 2" }, 142 | ]; 143 | 144 | function App(this: HTMLElement) { 145 | return ( 146 |
    147 | {() => 148 | createList((key) => 149 | items.map((item) => key(() =>
  • {item.text}
  • , item.id)) 150 | )} 151 |
152 | ); 153 | } 154 | 155 | const container = await mount(, document.body); 156 | 157 | assert.equal(container.querySelectorAll("li").length, 2); 158 | 159 | items = [ 160 | { id: 1, text: "Item 1" }, 161 | { id: 2, text: "Item 2" }, 162 | { id: 3, text: "Item 3" }, 163 | ]; 164 | 165 | update(container); 166 | 167 | const listItems = container.querySelectorAll("li"); 168 | assert.equal(listItems.length, 3); 169 | assert.equal(listItems[2].textContent, "Item 3"); 170 | }); 171 | 172 | test("createList: removes items when keys are missing", async () => { 173 | let items = [ 174 | { id: 1, text: "Item 1" }, 175 | { id: 2, text: "Item 2" }, 176 | { id: 3, text: "Item 3" }, 177 | ]; 178 | 179 | function App(this: HTMLElement) { 180 | return ( 181 |
    182 | {() => 183 | createList((key) => 184 | items.map((item) => key(() =>
  • {item.text}
  • , item.id)) 185 | )} 186 |
187 | ); 188 | } 189 | 190 | const container = await mount(, document.body); 191 | 192 | assert.equal(container.querySelectorAll("li").length, 3); 193 | 194 | items = [ 195 | { id: 1, text: "Item 1" }, 196 | { id: 3, text: "Item 3" }, 197 | ]; 198 | 199 | update(container); 200 | 201 | const listItems = container.querySelectorAll("li"); 202 | assert.equal(listItems.length, 2); 203 | assert.equal(listItems[0].textContent, "Item 1"); 204 | assert.equal(listItems[1].textContent, "Item 3"); 205 | }); 206 | 207 | test("createList: handles empty lists", async () => { 208 | const items: Array<{ id: number; text: string }> = []; 209 | 210 | function App(this: HTMLElement) { 211 | return ( 212 |
    213 | {() => 214 | createList((key) => 215 | items.map((item) => key(() =>
  • {item.text}
  • , item.id)) 216 | )} 217 |
218 | ); 219 | } 220 | 221 | const container = await mount(, document.body); 222 | 223 | assert.equal(container.querySelectorAll("li").length, 0); 224 | }); 225 | 226 | test("createList: handles list going from empty to filled", async () => { 227 | let items: Array<{ id: number; text: string }> = []; 228 | 229 | function App(this: HTMLElement) { 230 | return ( 231 |
    232 | {() => 233 | createList((key) => 234 | items.map((item) => key(() =>
  • {item.text}
  • , item.id)) 235 | )} 236 |
237 | ); 238 | } 239 | 240 | const container = await mount(, document.body); 241 | 242 | assert.equal(container.querySelectorAll("li").length, 0); 243 | 244 | items = [ 245 | { id: 1, text: "Item 1" }, 246 | { id: 2, text: "Item 2" }, 247 | ]; 248 | 249 | update(container); 250 | 251 | assert.equal(container.querySelectorAll("li").length, 2); 252 | }); 253 | 254 | await test.run(); 255 | -------------------------------------------------------------------------------- /src/tests/keyed-components.test.tsx: -------------------------------------------------------------------------------- 1 | import { assert, test } from "@marcisbee/rion/test"; 2 | import { mount } from "../../test/utils.ts"; 3 | import { createElement, createKey, update } from "../client.ts"; 4 | 5 | test("base", async () => { 6 | function Child(props: JSX.Props<{ value: number }>) { 7 | let renders = 0; 8 | return () => ( 9 |
{++renders} : {props().value}
10 | ); 11 | } 12 | 13 | function Parent(this: HTMLElement) { 14 | let count = 0; 15 | return ( 16 |
17 | 26 | {() => } 27 |
28 | ); 29 | } 30 | 31 | const root = await mount(, document.body); 32 | 33 | const button = root.querySelector("button")!; 34 | button.click(); 35 | 36 | assert.equal(root.querySelector(".child-count")!.textContent, "2 : 1"); 37 | 38 | button.click(); 39 | 40 | assert.equal(root.querySelector(".child-count")!.textContent, "3 : 2"); 41 | }); 42 | 43 | test("Child embedded in Parent", async () => { 44 | function Child(props: JSX.Props<{ value: number }>) { 45 | let renders = 0; 46 | return () => ( 47 |
{++renders} : {props().value}
48 | ); 49 | } 50 | 51 | function Parent(this: HTMLElement) { 52 | let count = 0; 53 | return ( 54 |
55 | 64 | {() => createKey(() => , String(count))} 65 |
66 | ); 67 | } 68 | 69 | const root = await mount(, document.body); 70 | 71 | const button = root.querySelector("button")!; 72 | button.click(); 73 | 74 | assert.equal(root.querySelector(".child-count")!.textContent, "1 : 1"); 75 | 76 | button.click(); 77 | 78 | assert.equal(root.querySelector(".child-count")!.textContent, "1 : 2"); 79 | }); 80 | 81 | test("Child passthrough Parent 1", async () => { 82 | function Child(props: JSX.Props<{ value: number }>) { 83 | let renders = 0; 84 | return () => ( 85 |
{++renders} : {props().value}
86 | ); 87 | } 88 | 89 | function Passthrough(props: JSX.PropsWithChildren) { 90 | return () => props().children; 91 | } 92 | 93 | function Parent(this: HTMLElement) { 94 | let count = 0; 95 | return ( 96 |
97 | 106 | 107 | {() => createKey(() => , String(count))} 108 | 109 |
110 | ); 111 | } 112 | 113 | const root = await mount(, document.body); 114 | 115 | const button = root.querySelector("button")!; 116 | button.click(); 117 | 118 | assert.equal(root.querySelector(".child-count")!.textContent, "1 : 1"); 119 | 120 | button.click(); 121 | 122 | assert.equal(root.querySelector(".child-count")!.textContent, "1 : 2"); 123 | }); 124 | 125 | test("Child passthrough Parent 2", async () => { 126 | function Child(props: JSX.Props<{ value: number }>) { 127 | let renders = 0; 128 | return () => ( 129 |
{++renders} : {props().value}
130 | ); 131 | } 132 | 133 | function Passthrough(props: JSX.PropsWithChildren) { 134 | return
{() => props().children}
; 135 | } 136 | 137 | function Parent(this: HTMLElement) { 138 | let count = 0; 139 | return ( 140 |
141 | 150 | 151 | {() => createKey(() => , String(count))} 152 | 153 |
154 | ); 155 | } 156 | 157 | const root = await mount(, document.body); 158 | 159 | const button = root.querySelector("button")!; 160 | button.click(); 161 | 162 | assert.equal(root.querySelector(".child-count")!.textContent, "1 : 1"); 163 | 164 | button.click(); 165 | 166 | assert.equal(root.querySelector(".child-count")!.textContent, "1 : 2"); 167 | }); 168 | 169 | test("Child passthrough Parent 3", async () => { 170 | function Child(props: JSX.Props<{ value: number }>) { 171 | let renders = 0; 172 | return () => ( 173 |
{++renders} : {props().value}
174 | ); 175 | } 176 | 177 | function Passthrough(props: JSX.PropsWithChildren) { 178 | return
{() => props().children}
; 179 | } 180 | 181 | function Parent(this: HTMLElement) { 182 | let count = 0; 183 | return ( 184 |
185 | 194 | 195 |
196 | {() => createKey(() => , String(count))} 197 |
198 |
199 |
200 | ); 201 | } 202 | 203 | const root = await mount(, document.body); 204 | 205 | const button = root.querySelector("button")!; 206 | button.click(); 207 | 208 | assert.equal(root.querySelector(".child-count")!.textContent, "1 : 1"); 209 | 210 | button.click(); 211 | 212 | assert.equal(root.querySelector(".child-count")!.textContent, "1 : 2"); 213 | }); 214 | 215 | test("Child passthrough Parent 4", async () => { 216 | function Child(props: JSX.Props<{ value: number }>) { 217 | let renders = 0; 218 | return () => ( 219 |
{++renders} : {props().value}
220 | ); 221 | } 222 | 223 | function Passthrough(props: JSX.PropsWithChildren) { 224 | const template = createElement("suspense", { 225 | style: () => ({ display: "contents" }), 226 | }, () => props().children); 227 | 228 | return [template]; 229 | } 230 | 231 | function Parent(this: HTMLElement) { 232 | let count = 0; 233 | return ( 234 |
235 | 244 | 245 |
246 | {() => createKey(() => , String(count))} 247 |
248 |
249 |
250 | ); 251 | } 252 | 253 | const root = await mount(, document.body); 254 | 255 | const button = root.querySelector("button")!; 256 | button.click(); 257 | 258 | assert.equal(root.querySelector(".child-count")!.textContent, "1 : 1"); 259 | 260 | button.click(); 261 | 262 | assert.equal(root.querySelector(".child-count")!.textContent, "1 : 2"); 263 | }); 264 | 265 | await test.run(); 266 | -------------------------------------------------------------------------------- /src/tests/server/ssr-props-events.test.tsx: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | /** 3 | * SSR prop/event handling & error boundary behavior tests for Radi server renderer. 4 | * 5 | * Verifies (client parity targets): 6 | * - style object + className normalization 7 | * - omission of function-valued props (events and plain functions) 8 | * - null / numeric / boolean attribute serialization rules (null omitted) 9 | * - attribute escaping 10 | * - error component string fallback (ERROR:Name) parity with client 11 | * - mixed success + error component chain 12 | * - multiple error components do not break overall serialization 13 | */ 14 | 15 | import { assert, test } from "@marcisbee/rion/test"; 16 | import { 17 | createElement as h, 18 | Fragment, 19 | renderToStringRoot, 20 | } from "../../server.ts"; 21 | 22 | /* -------------------------------------------------------------------------- */ 23 | /* Helpers */ 24 | /* -------------------------------------------------------------------------- */ 25 | 26 | function includes(html: string, fragment: string) { 27 | assert.equal( 28 | html.includes(fragment), 29 | true, 30 | `Expected HTML to include fragment:\n${fragment}\n---\nHTML:\n${html}`, 31 | ); 32 | } 33 | 34 | function notIncludes(html: string, fragment: string) { 35 | assert.equal( 36 | html.includes(fragment), 37 | false, 38 | `Did NOT expect HTML to include fragment:\n${fragment}\n---\nHTML:\n${html}`, 39 | ); 40 | } 41 | 42 | /* -------------------------------------------------------------------------- */ 43 | /* Components */ 44 | /* -------------------------------------------------------------------------- */ 45 | 46 | function Good(props: () => { label: string }) { 47 | return h( 48 | "div", 49 | { className: "good", "data-label": props().label }, 50 | "Good:", 51 | props().label, 52 | ); 53 | } 54 | 55 | function Throws(_props: () => Record) { 56 | throw new Error("explode"); 57 | } 58 | 59 | function ThrowsLate(props: () => { text: string }) { 60 | if (props().text.length > 0) { 61 | throw new Error("late"); 62 | } 63 | return h("span", null, "will-not-render"); 64 | } 65 | 66 | function Mixed(props: () => { a: string; b: string }) { 67 | return [ 68 | h(Good, { label: props().a }), 69 | h(Throws, null), 70 | h(Good, { label: props().b }), 71 | h(ThrowsLate, { text: props().a + props().b }), 72 | ]; 73 | } 74 | 75 | /* -------------------------------------------------------------------------- */ 76 | /* Tests: Props & Events */ 77 | /* -------------------------------------------------------------------------- */ 78 | 79 | test("ssr: style object & className normalization + primitive props", () => { 80 | const html = renderToStringRoot( 81 | h( 82 | "div", 83 | { 84 | className: "foo bar", 85 | style: { backgroundColor: "black", fontSize: "12px" }, 86 | dataNull: null, 87 | dataNum: 7, 88 | dataBoolTrue: true, 89 | title: '<>&"', 90 | }, 91 | "content", 92 | ), 93 | ); 94 | 95 | includes(html, '
content
"); 103 | }); 104 | 105 | test("ssr: event handler props are omitted (onClick/onInput)", () => { 106 | const html = renderToStringRoot( 107 | h( 108 | "button", 109 | { 110 | onClick: () => console.log("clicked"), 111 | onInput: () => {}, 112 | className: "btn", 113 | }, 114 | "Push", 115 | ), 116 | ); 117 | 118 | includes(html, ''); 119 | notIncludes(html, "onClick"); 120 | notIncludes(html, "onInput"); 121 | }); 122 | 123 | test("ssr: plain function-valued non-event prop omitted", () => { 124 | const html = renderToStringRoot( 125 | h("div", { compute: () => 42, id: "fn-test" }, "X"), 126 | ); 127 | includes(html, '
X
'); 128 | notIncludes(html, "compute="); 129 | }); 130 | 131 | test("ssr: boolean primitive child serialized as comment, null child comment", () => { 132 | const html = renderToStringRoot( 133 | h("div", null, true, null, false), 134 | ); 135 | includes(html, ""); 136 | includes(html, ""); 137 | includes(html, ""); 138 | }); 139 | 140 | /* -------------------------------------------------------------------------- */ 141 | /* Tests: Error Boundary / Component Errors */ 142 | /* -------------------------------------------------------------------------- */ 143 | 144 | test("ssr: single error component produces fallback marker (parity ERROR:Name)", () => { 145 | const html = renderToStringRoot( 146 | h("section", null, h(Throws, null)), 147 | ); 148 | includes(html, "
"); 149 | includes(html, "ERROR:Throws"); 150 | notIncludes(html, ""); 153 | }); 154 | 155 | test("ssr: mixed good + error components retain good output", () => { 156 | const html = renderToStringRoot( 157 | h("main", null, h(Mixed, { a: "A", b: "B" })), 158 | ); 159 | includes(html, "
"); 160 | includes(html, "Good:A"); 161 | includes(html, "Good:B"); 162 | includes(html, "ERROR:Throws"); 163 | includes(html, "ERROR:ThrowsLate"); 164 | notIncludes(html, "component-error"); 165 | notIncludes(html, ""); 167 | }); 168 | 169 | test("ssr: nested error inside fragment does not break siblings", () => { 170 | const html = renderToStringRoot( 171 | h( 172 | Fragment, 173 | null, 174 | h(Good, { label: "inside-frag" }), 175 | h(Throws, null), 176 | h("span", null, "tail"), 177 | ), 178 | ); 179 | // Client parity: no fragment boundary comments emitted. 180 | notIncludes(html, ""); 181 | notIncludes(html, ""); 182 | includes(html, "inside-frag"); 183 | includes(html, "ERROR:Throws"); 184 | includes(html, "tail"); 185 | }); 186 | 187 | test("ssr: multiple throwing components sequentially", () => { 188 | const html = renderToStringRoot( 189 | h( 190 | "div", 191 | null, 192 | h(Throws, null), 193 | h(ThrowsLate, { text: "X" }), 194 | h(Throws, null), 195 | ), 196 | ); 197 | const countThrows = html.split("ERROR:Throws").length - 1; 198 | // Some environments may report ThrowsLate also as ERROR:Throws; accept >=2 199 | assert.equal( 200 | countThrows >= 2, 201 | true, 202 | `Expected >=2 ERROR:Throws, got ${countThrows}`, 203 | ); 204 | notIncludes(html, "component-error"); 205 | notIncludes(html, ""); 207 | includes(html, "
"); 208 | }); 209 | 210 | /* -------------------------------------------------------------------------- */ 211 | /* Run */ 212 | /* -------------------------------------------------------------------------- */ 213 | 214 | await test.run(); 215 | --------------------------------------------------------------------------------