diff --git a/.gitignore b/.gitignore index 6ca0fee..09e6252 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,9 @@ dist/ # Tests coverage/ *.test.ts.snap +# Playwright's run state, rewritten on every browser-test run. Nothing in it is +# an input to anything — it exists so `--last-failed` can re-run failures. +test-results/ # OS .DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c246d8..2093e53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,135 @@ This project follows [Semantic Versioning](https://semver.org/). --- --- +## [4.1.0] — 2026-09-03 + +Progressive-enhancement ergonomics, driven by building a complete chess +application as a single enhanced island. Two additive public APIs, two +correctness fixes, and the documentation the pattern was missing. No breaking +changes — a minor release because the public surface grew. + +### Added + +- **`external()` — reactive integration with state SibuJS does not own.** A + domain engine, a canvas scene graph, an editor document, a cache a socket + writes into: the runtime cannot see writes into objects it does not own, and + now says so with a primitive instead of leaving every application to invent a + revision counter. `source.track()` inside a getter declares "this reads the + outside world"; `source.invalidate()` at the mutation site publishes the + change. Tracking and invalidation are separate calls because they genuinely + happen in different places. + + It never proxies, clones or diffs your object — it holds no reference to it. + `invalidate()` is a signal write, so it participates in `batch()`, works + inside `derived()` and `effect()`, respects consumer ownership (a disposed + binding, effect or island is never invalidated), and routes a throwing + consumer through the ordinary error pipeline with that consumer's own phase + and node. It costs ~60 bytes gzipped on top of `signal` + `effect` and + tree-shakes out when unused. + + One source is one invalidation domain; several sources give a feature + independent update rates. See `docs/architecture/external-state.md` for the + four state architectures, their costs, and how to profile invalidation + fan-out. + + It is defined in `src/core/signals/signal.ts` rather than a module of its own, + and deliberately so: the build splits `dist/` into shared chunks, and a module + reachable only from the root entry lands in the index-only chunk beside + `enhance`, `mountIslands`, `mount` and `each`. Importing it from `"sibujs"` + would then have pulled the whole island runtime into a page that only wanted + to make a canvas reactive — 77 KB instead of 9 KB, measured across esbuild, + Rollup, Vite and webpack. + +- **`ctx.each(target, describe)` — repeated enhancement bindings.** A board, a + table, a keyboard, a legend: many elements the server already rendered, each + needing several bindings. The callback receives the element and its index and + returns a descriptor (`text`, `attr`, `class`, `show`, `on`, `cleanup`); every + field is committed through the matching `ctx.*` helper, so ownership, + disposal, attribute sanitization, write elision and error metadata are the + same objects as the hand-written loop. + + It is sugar and deliberately nothing more: no expression parsing, no + interpolation, no `eval`, no new DOM, no node moved or replaced. Targets may + be a `@ref`/CSS selector or any iterable of elements; zero matches is a silent + no-op; a descriptor mistake throws in development naming the element index and + the offending key, inside `setup`, so the enhancement transaction rolls back. + Anything the descriptor does not cover — `model`, listener options, a nested + `enhance` — is written imperatively in the same callback, which receives the + element. + + Measured at +9% setup cost for 64 elements × 4 bindings in a production build, + and identical at update time. + +### Fixed + +- **An `enhance()` binding that throws on a later update now reaches the + enclosing `ErrorBoundary`.** Every reactive helper on `EnhanceContext` + (`text`, `attr`, `classed`, `show`, `model`) created its binding with + `effect()`. An effect subscriber is stamped `phase: "effect"` and deliberately + carries no owner node, because a generic effect has no DOM position. So when + such a binding threw on a scheduled re-run — the only path where the + notification drain, rather than the caller, reports the failure — it was + reported as an effect with `node: undefined`. Since a boundary is located from + the failing node, that branch was unreachable for every progressive- + enhancement binding on the page, and the error fell straight through to the + configured runtime handler or the console. + + All five now bind through `reactiveBinding(commit, el)` and report + `phase: "binding"` with the element they own, matching every other DOM binding + in the runtime. An error no boundary claims still falls through to the handler + and then the console exactly as before. Server-side behaviour is unchanged: + bindings created during SSR remain inert, as they were under `effect()`. + +- **A reactive binding whose FIRST evaluation throws no longer survives as a + zombie.** `reactiveBinding()` ran its initial commit before it constructed or + returned the disposer. A commit that read a signal and then threw had already + been linked to that signal by `retrack()`, but the throw escaped before any + disposer existed — so no caller could ever hold one. For `enhance()` that + silently voided the documented transaction guarantee: the setup error was + caught and the enhancement rolled back, yet the failing binding was never in + the teardown list to roll back. The next write to that signal re-ran the + commit and mutated DOM belonging to an enhancement that had already been + abandoned. + + The initial run now unwinds on failure: the subscriber is marked disposed (so + anything already queued for it in the current drain is skipped), its owner + node is dropped so a failed binding cannot retain a DOM subtree, its edges are + released through the same `cleanup()` every disposal uses, and the original + error object is rethrown untouched. A failed enhancement now leaves zero live + subscriptions, mutates nothing afterwards, and leaves the root enhanceable + again — which is what "a failed setup claims nothing" always promised. + Successful bindings, later re-runs, later failures routed through the error + pipeline, reentrancy protection, disposal idempotence and SSR inertness are + all unchanged. + +### Documentation + +- `docs/islands.md` rewritten as the complete guide: the `EnhanceContext`, + repeated enhancement, external mutable state, `enhance()` vs `mount()` and how + to combine them in one feature, feature-local state, granular vs broad + invalidation, lifecycle and cleanup, accessible conditional UI, performance + profiling, an architecture decision table, and a table of common mistakes with + their fixes. +- `docs/architecture/external-state.md` — four state architectures compared on + complexity, runtime cost, memory, granularity and integration effort, with + measurements on a 64-cell grid and how to profile invalidation fan-out. +- `docs/interop.md` — nine rules for running islands inside a page another + framework owns, with two implementations verified in Chromium, Firefox and + WebKit. +- `examples/chess/` — a complete chess game as an enhanced island: 64 + server-rendered squares, `ctx.each`, `external()` invalidation, per-square + signals for the interaction hot path, a mounted move-history region, keyboard + grid navigation, an accessible promotion dialog, two independent boards, and a + deliberately broken island beside them. The rules come from `chess.js`, which + is an example/development dependency only and is not reachable from any + package entry point. +- `examples/interop-host.html` — a host framework that owns the page and swaps + its content on client-side navigation, including the failure mode you get from + skipping the disposer. + +--- +--- + ## [4.0.1] — 2026-08-29 An error-routing fix for reactive `class` and `style` bindings. No breaking changes. diff --git a/README.md b/README.md index 6db6903..9c3703b 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,10 @@ The lean core: reactivity, rendering, and components. Everything below imports f ### Islands & Progressive Enhancement - **`enhance` / `enhanceAll`** — attach fine-grained reactivity to existing server-rendered HTML with no build step (the third rendering mode alongside `mount` and `hydrate`). - **`island` / `registerIsland` / `hydrateIslands`** — hydrate isolated interactive regions on demand. +- **`ctx.each`** — bind many elements the server already rendered (a board, a table, a keyboard) from one descriptor, with no new DOM. +- **`external`** — reactive integration with state SibuJS does not own: a domain engine, a canvas, an editor, a socket cache. `track()` where you read, `invalidate()` where you mutate. + +Guides: [islands & progressive enhancement](docs/islands.md) · [external mutable state and update granularity](docs/architecture/external-state.md) · [running islands inside another framework's page](docs/interop.md). Reference application: [`examples/chess/`](examples/chess/). --- diff --git a/bench/islands-dx.mjs b/bench/islands-dx.mjs new file mode 100644 index 0000000..095c303 --- /dev/null +++ b/bench/islands-dx.mjs @@ -0,0 +1,305 @@ +// --------------------------------------------------------------------------- +// Islands / enhancement DX benchmark. +// +// node bench/islands-dx.mjs (npm run bench:islands-dx) +// +// Measures the cost of the things this pass added, and the cost of the state +// architectures the docs recommend choosing between, on a real 64-square board: +// +// 1. external() invalidation vs a plain signal write +// 2. binding 64 elements with ctx.each vs the equivalent ctx.* calls +// 3. broad invalidation (whole engine) vs granular per-square signals +// 4. enhance → dispose stability over many cycles +// 5. the bundle cost of each new public primitive +// +// It reports what it measured. There is no baseline file and no pass/fail: the +// numbers are only meaningful next to the environment printed at the top. +// --------------------------------------------------------------------------- + +import { build } from "esbuild"; +import { JSDOM } from "jsdom"; +import { gzipSync } from "node:zlib"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +const dom = new JSDOM("
"); +for (const key of ["document", "HTMLElement", "Element", "Node", "Comment", "Event", "KeyboardEvent"]) { + globalThis[key] = dom.window[key]; +} + +const { signal, effect, batch, enhance, external } = await import("../dist/index.js"); + +const FILES = ["a", "b", "c", "d", "e", "f", "g", "h"]; +const RANKS = ["1", "2", "3", "4", "5", "6", "7", "8"]; +const SQUARES = RANKS.flatMap((r) => FILES.map((f) => `${f}${r}`)); + +function makeBoard() { + const root_ = document.createElement("section"); + root_.innerHTML = SQUARES.map( + (sq) => ``, + ).join(""); + document.body.appendChild(root_); + return root_; +} + +function time(label, iterations, fn, { warmup = 3 } = {}) { + for (let i = 0; i < warmup; i++) fn(); + const start = performance.now(); + for (let i = 0; i < iterations; i++) fn(); + const elapsed = performance.now() - start; + const perOp = elapsed / iterations; + const unit = perOp < 0.001 ? `${(perOp * 1e6).toFixed(0)} ns` : `${(perOp * 1000).toFixed(2)} µs`; + console.log(` ${label.padEnd(52)} ${unit.padStart(12)}/op ${elapsed.toFixed(1).padStart(8)} ms total`); + return perOp; +} + +function section(title) { + console.log(`\n── ${title} ${"─".repeat(Math.max(0, 64 - title.length))}`); +} + +console.log("SibuJS — islands / enhancement DX benchmark"); +console.log(`node ${process.version} · ${process.platform} · jsdom`); +console.log( + `NODE_ENV=${process.env.NODE_ENV ?? "(unset → dev)"} — dev mode also runs ctx.each's descriptor validation, ` + + "which a production browser bundle drops. Run with NODE_ENV=production for the shipped cost.", +); + +// --- 1. invalidation cost --------------------------------------------------- + +section("1. external() invalidation vs a plain signal write"); +{ + const CONSUMERS = 64; + + const [n, setN] = signal(0); + const src = external(); + let sink = 0; + for (let i = 0; i < CONSUMERS; i++) { + effect(() => { + n(); + sink++; + }); + effect(() => { + src.track(); + sink++; + }); + } + + let v = 0; + time(`signal write → ${CONSUMERS} effects`, 20_000, () => setN(++v)); + time(`external.invalidate() → ${CONSUMERS} effects`, 20_000, () => src.invalidate()); + time("external.invalidate() with no consumers", 200_000, () => external().invalidate()); + console.log(` (sink=${sink}, kept so nothing is optimised away)`); +} + +// --- 2. ctx.each vs hand-written ctx.* calls -------------------------------- + +section("2. Binding 64 squares: ctx.each vs the equivalent ctx.* calls"); +{ + const [selected, setSelected] = signal(null); + const marks = new Map(SQUARES.map((sq) => [sq, signal("")])); + + // The board is created ONCE and re-enhanced, so the measurement is the + // binding work — not jsdom's innerHTML parser, which would otherwise dominate + // and hide the very difference being measured. A disposed root is + // enhanceable again, which is what makes this legal. + const boardA = makeBoard(); + const boardB = makeBoard(); + + const imperative = () => { + enhance(boardA, (ctx) => { + for (const el of ctx.refs("@square")) { + const sq = el.dataset.square; + const [mark] = marks.get(sq); + ctx.attr(el, "data-marks", () => mark()); + ctx.attr(el, "aria-selected", () => selected() === sq); + ctx.classed(el, "sel", () => selected() === sq); + ctx.on(el, "click", () => setSelected(sq)); + } + })(); + }; + + const declarative = () => { + enhance(boardB, (ctx) => { + ctx.each("@square", (el) => { + const sq = el.dataset.square; + const [mark] = marks.get(sq); + return { + attr: { "data-marks": () => mark(), "aria-selected": () => selected() === sq }, + class: { sel: () => selected() === sq }, + on: { click: () => setSelected(sq) }, + }; + }); + })(); + }; + + const a = time("64 squares × 4 bindings — direct ctx.* calls", 2000, imperative); + const b = time("64 squares × 4 bindings — ctx.each", 2000, declarative); + const delta = ((b - a) / a) * 100; + console.log(` ctx.each vs hand-written: ${delta >= 0 ? "+" : ""}${delta.toFixed(1)}% (setup only; re-runs go through the same bindings)`); + boardA.remove(); + boardB.remove(); +} + +// --- 3. broad vs granular --------------------------------------------------- + +section("3. One move: broad invalidation vs per-square signals"); +{ + // Broad: every square's glyph binding reads the engine through one source. + const boardBroad = makeBoard(); + const engine = new Map(SQUARES.map((sq) => [sq, ""])); + const position = external(); + let broadRuns = 0; + enhance(boardBroad, (ctx) => { + ctx.each("@piece", (el, i) => ({ + text: () => { + position.track(); + broadRuns++; + return engine.get(SQUARES[i]); + }, + })); + }); + + // Granular: one signal per square; a move writes only the squares it touched. + const boardFine = makeBoard(); + const cells = new Map(SQUARES.map((sq) => [sq, signal("")])); + let fineRuns = 0; + enhance(boardFine, (ctx) => { + ctx.each("@piece", (el, i) => { + const [value] = cells.get(SQUARES[i]); + return { + text: () => { + fineRuns++; + return value(); + }, + }; + }); + }); + + let step = 0; + const broadMove = () => { + const from = SQUARES[step % 64]; + const to = SQUARES[(step + 8) % 64]; + step++; + engine.set(from, ""); + engine.set(to, "P"); + position.invalidate(); + }; + + let step2 = 0; + const fineMove = () => { + const from = SQUARES[step2 % 64]; + const to = SQUARES[(step2 + 8) % 64]; + step2++; + batch(() => { + cells.get(from)[1](""); + cells.get(to)[1]("P"); + }); + }; + + const before = { broadRuns, fineRuns }; + const broad = time("broad: 1 invalidation → 64 binding re-runs", 20_000, broadMove); + const fine = time("granular: 2 signal writes → 2 binding re-runs", 20_000, fineMove); + console.log( + ` binding executions per move — broad: ${((broadRuns - before.broadRuns) / 20_003).toFixed(1)}` + + `, granular: ${((fineRuns - before.fineRuns) / 20_003).toFixed(1)}`, + ); + console.log(` granular is ${(broad / fine).toFixed(1)}× faster per move at 64 cells`); + console.log(" → for 64 cells both are far under one frame; see docs/architecture/external-state.md"); +} + +// --- 4. mount/dispose stability --------------------------------------------- + +section("4. enhance → mutate → dispose stability"); +{ + const cycles = 2000; + const src = external(); + const rootsBefore = document.querySelectorAll("[data-sibu-enhanced]").length; + const board = makeBoard(); + const start = performance.now(); + for (let i = 0; i < cycles; i++) { + const stop = enhance(board, (ctx) => { + ctx.each("@square", (el) => ({ + attr: { "data-i": () => { src.track(); return String(i); } }, + on: { click: () => {} }, + })); + }); + src.invalidate(); + stop(); + } + const elapsed = performance.now() - start; + const rootsAfter = document.querySelectorAll("[data-sibu-enhanced]").length; + board.remove(); + console.log(` ${cycles} cycles of (enhance 64 squares → invalidate → dispose) on ONE board`); + console.log(` ${(elapsed / cycles).toFixed(3)} ms/cycle`); + console.log(` enhanced roots leaked by this section: ${rootsAfter - rootsBefore} (must be 0)`); + console.log(" (heap is deliberately not reported: without a forced GC the number says nothing)"); +} + +// --- 5. bundle cost --------------------------------------------------------- + +section("5. Bundle cost of each new primitive (minified + gzipped)"); +{ + const bundle = async (entry) => { + const result = await build({ + stdin: { contents: entry, resolveDir: root, loader: "ts" }, + bundle: true, + minify: true, + format: "esm", + target: "es2020", + write: false, + define: { __SIBU_DEV__: "false", __SIBU_VERSION__: '"bench"' }, + }); + const code = result.outputFiles[0].contents; + return { min: code.length, gz: gzipSync(code).length }; + }; + + const kb = (n) => `${(n / 1024).toFixed(2)} KB`; + const rows = [ + ["signal + effect (external lives here)", `export { signal, external } from "./src/core/signals/signal"; + export { effect } from "./src/core/signals/effect";`], + ["signal + effect, external unused", `export { signal } from "./src/core/signals/signal"; + export { effect } from "./src/core/signals/effect";`], + ["enhance + islands (includes ctx.each)", `export { signal, external } from "./src/core/signals/signal"; + export { effect } from "./src/core/signals/effect"; + export { enhance } from "./src/platform/enhance"; + export { registerIsland, mountIslands } from "./src/platform/islands";`], + ]; + + const sizes = []; + for (const [label, entry] of rows) { + const { min, gz } = await bundle(entry); + sizes.push(gz); + console.log(` ${label.padEnd(40)} ${kb(min).padStart(10)} min ${kb(gz).padStart(10)} gz`); + } + console.log(` external() itself: ${kb(sizes[0] - sizes[1])} gz`); + console.log(" note: `each` is part of the enhance module, so it is included in the enhance row."); + + // The property that matters to a consumer: importing `external` from the + // PACKAGE ROOT must not drag the island runtime in. It used to, because a + // module reachable only from the root entry lands in the index-only chunk. + const distProbe = await build({ + stdin: { + contents: [ + 'import { external, signal } from "./dist/index.js";', + "const s = external(); s.track(); s.invalidate();", + "const [n] = signal(0); console.log(n());", + ].join("\n"), + resolveDir: root, + loader: "js", + }, + bundle: true, + minify: true, + format: "esm", + platform: "node", + write: false, + logLevel: "silent", + }).then((r) => r.outputFiles[0].text); + console.log( + ` from the packaged root, external + signal bundles to ${kb(distProbe.length)} ` + + `(island runtime present: ${distProbe.includes("sibujs.islands.registry.v1")})`, + ); +} + +console.log(""); diff --git a/docs/architecture/external-state.md b/docs/architecture/external-state.md new file mode 100644 index 0000000..2305e40 --- /dev/null +++ b/docs/architecture/external-state.md @@ -0,0 +1,281 @@ +# External mutable state, invalidation domains, and update granularity + +SibuJS tracks reads of **its own** signals. A chess engine, a canvas scene +graph, a CodeMirror document, a media element, a cache a WebSocket writes into — +all of these keep their state in objects the runtime never sees written. + +This document is about the seam between those two worlds: the primitive that +connects them, the four state architectures you can build on it, and how to tell +which one a feature actually needs. + +--- + +## Why there is no automatic answer + +A framework can only observe what it owns. Making arbitrary third-party mutation +observable would require one of: + +| Approach | Why SibuJS does not do it | +| --- | --- | +| Wrap the object in a `Proxy` | Breaks `instanceof`, private fields, and any internal `this` identity check. A `Chess` instance behind a proxy is no longer the object its own methods were written against. | +| Deep-clone and diff | Turns every read into a structural comparison, and doubles memory for state you did not want to own. | +| Patch the library's methods | Silently breaks on the next version, and cannot see writes that do not go through a method. | +| Poll | Wrong answers between polls; wasted work when nothing changed. | + +So SibuJS does the honest thing: it gives you a **valueless reactive token** and +asks you to say when the outside world changed. + +```ts +import { external } from "sibujs"; + +const game = new Chess(); // owns the rules and the mutable state +const moved = external(); // owns "something changed" + +ctx.text("@status", () => { + moved.track(); // this binding reads the engine + return game.isCheckmate() ? "Checkmate" : `${game.turn()} to move`; +}); + +game.move({ from: "e2", to: "e4" }); +moved.invalidate(); // every consumer above re-reads +``` + +`track()` and `invalidate()` are deliberately two calls, because the two things +they represent genuinely happen in different places: a getter reads, a command +handler mutates. Anything that collapses them has to guess. + +### What `external()` guarantees + +- It never proxies, clones or inspects your object. It has no reference to it. +- `invalidate()` is a signal write: it participates in `batch()`, so a burst of + mutations inside one batch notifies consumers once. +- It works anywhere a signal read works — bindings, `effect()`, `derived()`. +- Consumers keep their own ownership. A disposed effect, a disposed binding or a + disposed island is **never** invalidated. +- A consumer that throws is reported through the ordinary runtime error pipeline + with its own phase and node — a binding stays a `"binding"` with its element, + an effect stays an `"effect"`. +- It costs about 60 bytes gzipped on top of `signal` + `effect`. + +### What it does not do + +It does not make your engine reactive. If you mutate and forget to +`invalidate()`, the UI is stale — and that is a bug in your code that no +framework can find for you. The one place this bites in practice is a library +that mutates **asynchronously on its own** (a socket pushing frames, a media +element firing `timeupdate`): subscribe to whatever event it exposes and call +`invalidate()` from there. + +--- + +## The four architectures + +Every interactive grid, editor, dashboard or media tool ends up choosing between +these. They compose — a real feature usually uses two. + +### 1. One invalidation domain for the whole engine + +```ts +const engine = new Whatever(); +const changed = external(); + +// every consumer starts with changed.track() +``` + +| | | +| --- | --- | +| Complexity | Lowest. One line at each mutation site. | +| Runtime cost | Every consumer re-runs on every change. | +| Memory | One reactive source. | +| Granularity | None — all or nothing. | +| Integration effort | Minutes. | + +**Reach for it first.** It is correct by construction: there is exactly one place +that can forget to publish a change, and forgetting is loud (nothing updates). + +### 2. Several invalidation domains + +```ts +const position = external(); // where the pieces are +const clock = external(); // the timer, ticking every second +const chat = external(); // messages arriving over a socket +``` + +| | | +| --- | --- | +| Complexity | Low. You now have to know which domain a mutation belongs to. | +| Runtime cost | Only the consumers of the affected domain re-run. | +| Memory | One source per domain. | +| Granularity | Per subsystem. | +| Integration effort | Small. | + +**The highest-value refactor of the four**, because it is usually driven by a +real structural fact rather than by a micro-benchmark: a clock that ticks once a +second has no business re-running 64 board bindings. Split when two parts of the +engine change at genuinely different *rates*, not when one part is merely big. + +### 3. One signal per cell + +```ts +const cells = new Map(SQUARES.map((sq) => [sq, signal("")])); + +// a move writes only the squares it touched +batch(() => { + cells.get(from)[1](""); + cells.get(to)[1]("P"); +}); +``` + +| | | +| --- | --- | +| Complexity | Medium. Something must now compute *which* cells changed. | +| Runtime cost | Only the changed cells re-run. Writing an unchanged value costs a comparison and notifies nobody. | +| Memory | One signal (~100 bytes) per cell. | +| Granularity | Per cell. | +| Integration effort | Moderate — you are mirroring engine state, and mirrors go stale. | + +**Reach for it on the hot path, not the whole feature.** In the chess reference +this is used for *highlighting* (which changes on every click) and not for piece +glyphs (which change on every move — far less often). + +The trick that makes it cheap: recompute the whole map and write every cell. +Signal equality turns unchanged writes into no-ops, so "write all 64" costs 64 +comparisons and wakes only the two bindings that actually changed. You get +granularity without having to work out the diff yourself. + +### 4. A normalized store mirroring the engine + +```ts +const [board, { setState }] = store({ pieces: {...}, turn: "w", history: [] }); +``` + +| | | +| --- | --- | +| Complexity | Highest. Two models of the same truth, kept in step by hand. | +| Runtime cost | Per-key granularity, plus the cost of maintaining the mirror. | +| Memory | A full second copy of the state. | +| Granularity | Per key. | +| Integration effort | Large, and permanent — every engine feature must be mirrored. | + +**Usually the wrong trade for a domain engine you did not write.** It is right +when the engine is a *source* rather than the owner: data arriving over a socket, +a document you are editing locally, anything where SibuJS state is the real +state and the external system is an input or an output. If the external library +owns the truth, mirroring it means two truths. + +--- + +## Measured, on a 64-square board + +From `npm run bench:islands-dx` (Node 24.19.0, win32, jsdom — your numbers will +differ; the ratio is the point): + +``` +broad: 1 invalidation → 64 binding re-runs 15.92 µs/move +granular: 2 signal writes → 2 binding re-runs 4.69 µs/move + ~3.4× faster +``` + +Read that carefully before optimising anything. The "slow" architecture costs +**16 microseconds per move** — about 0.1% of a 16 ms frame. For a 64-cell grid, +broad invalidation is not a performance problem; it is a perfectly good default, +and choosing it costs you nothing you would ever see. + +The picture changes with scale and rate, and both matter: + +| Cells | Updates per second | Reasonable choice | +| --- | --- | --- | +| ≤ 100 | any | Broad invalidation (1) | +| ≤ 100 | but one subsystem ticks independently | Split domains (2) | +| 100 – 5 000 | on interaction (clicks, keys) | Per-cell signals (3) for the interactive layer | +| 100 – 5 000 | on a stream / animation frame | Per-cell signals (3), plus batching per frame | +| > 5 000 | any | Per-cell signals, and virtualize — do not bind cells you are not showing | + +The rule of thumb that survives contact with real applications: **granularity +follows update FREQUENCY, not collection SIZE.** A 400-cell spreadsheet that +recalculates on blur is fine with broad invalidation. A 64-cell board that +repaints on `pointermove` is not. + +--- + +## Profiling: measuring invalidation fan-out + +Before optimising, measure. Two techniques, no framework support required. + +### Count binding executions + +The cheapest and most decisive measurement — how many bindings actually ran: + +```ts +let runs = 0; +ctx.each("@cell", (el, i) => ({ + text: () => { + runs++; // remove once you have your answer + changed.track(); + return engine.get(i); + }, +})); + +// then, around one interaction: +const before = runs; +play(move); +console.log("bindings re-run:", runs - before); +``` + +If that number is 64 for a two-square change, you have found your fan-out. If it +is 2, granularity is not your problem and you should measure something else. + +### Time the update, not the framework + +```ts +performance.mark("move:start"); +game.move(m); +changed.invalidate(); // synchronous: bindings have run by the next line +performance.mark("move:end"); +performance.measure("move", "move:start", "move:end"); +``` + +Invalidation is synchronous outside a `batch()`, so the measure covers the DOM +writes too. Inside a `batch()`, put the end mark after the batch returns. + +### The tools that are already there + +- **`npm run bench:islands-dx`** — the benchmark the numbers above come from. + Copy it and swap in your own grid; it is 200 lines with no harness. +- **`sibujs/devtools`** — `external({ name: "chess:position" })` labels the + source, and the devtools hook receives `signal:create` / `signal:update` + events for it like any other signal, so an invalidation storm is visible by + name. +- **Chrome DevTools performance panel** — a broad invalidation is one + synchronous task. If it does not show up in a profile, it is not your problem. + +There is deliberately **no built-in "count effect executions" API**. It would +have to live on the hot path of every subscriber to be accurate, and the six +lines above answer the same question with zero production cost. The devtools +package already carries the general-purpose hooks for anything more. + +--- + +## Generalising away from chess + +| Domain | Natural invalidation domains | +| --- | --- | +| Spreadsheet / data grid | The sheet's cell store; selection; the formula-engine recalculation pass | +| Code editor | Document text; decorations/diagnostics; cursor & selection | +| Dashboard | Each data source; the time range; layout/edit mode | +| Diagram editor | Graph topology; per-node geometry; viewport/zoom | +| Card game | The deck/engine; the local player's hand; the animation clock | +| Media tool | The media element's time (`timeupdate`); the waveform/analysis buffer; transport state | + +In every row, the split is by **who writes and how often**, not by what the data +is about. Selection and viewport change on pointer input; documents change on +edit; streams change on their own schedule. Give each rate its own source. + +--- + +## See also + +- [`docs/islands.md`](../islands.md) — `enhance()` vs `mount()`, `ctx.each`, and + the chess reference. +- [`examples/chess/chess-island.js`](../../examples/chess/chess-island.js) — all + three of the first architectures in one file, labelled. diff --git a/docs/hardening/islands-dx-findings.md b/docs/hardening/islands-dx-findings.md new file mode 100644 index 0000000..f639de0 --- /dev/null +++ b/docs/hardening/islands-dx-findings.md @@ -0,0 +1,347 @@ +# Islands / enhancement DX findings + +Findings from building a complete chess application as an enhanced SibuJS +island, audited against the source before any production code changed. + +Every claim below was verified by reading the implementation, and every defect +was reproduced with a failing test before it was fixed. Friction that turned out +**not** to be a defect is recorded as such, with what was done instead. + +Severities: **P0** critical · **P1** high · **P2** medium · **P3** low + +## Baseline + +| | | +|---|---| +| Package | `sibujs@4.0.1` (this work released as `4.1.0`) | +| Node | v24.19.0 · npm 11 · Windows 11 (win32-x64) | +| Vitest | 3.2.7 · jsdom 26 | +| Playwright | 1.61.1 — Chromium, Firefox, WebKit installed | +| Full suite **before** | 449 files, 6 320 passing, 1 skipped | +| `tsc --noEmit` / `biome check` **before** | clean | + +The application under audit is `sibujs-chess/app/chess-island.tsx` — a working +board scoring ~8.5/10 for this class of application. Its architecture was +confirmed sound: server-rendered squares, `registerIsland` + `mountIslands`, one +`enhance()` transaction, per-square bindings, no rerender, no VDOM. + +--- + +## Verified against the source, not the prompt + +Four claims in the brief did not survive contact with the code and are corrected +here so nothing downstream repeats them: + +| Claim | Reality | +|---|---| +| `setupChess(root, ctx)` | An island setup is `EnhanceSetup = (ctx) => void \| (() => void)`. There is no `root` parameter; the element is `ctx.root`. The chess app already calls it correctly. | +| "`ctx.refs()` … existing list helpers" | `ctx.refs()` is a query, not a binding helper. No repeated-binding helper existed on `EnhanceContext`. | +| The floor is Chrome 80 / FF 78 / Safari 14 / Edge 80 | `package.json` declares Chrome ≥ 93, Firefox ≥ 92, Safari ≥ 15.4, Edge ≥ 93, and `tests/hardening-browser-floor.test.ts` enforces it. The **repository's** declared floor was maintained; nothing was changed to accommodate the lower numbers. | +| "Profiling reactive invalidation" is missing | `sibujs/devtools` already receives `signal:create` / `signal:update` for every source. What was missing was documentation, not machinery. | + +--- + +## DX-001 — enhancement bindings were reported as generic effects, with no node + +**P1 · fixed** + +### Description + +Every reactive helper on `EnhanceContext` (`text`, `attr`, `classed`, `show`, +`model`) created its binding with `effect()`. An `effect` subscriber is stamped +`_errorPhase: "effect"` and deliberately carries **no** `_errorNode` — a generic +effect has no DOM position, and attaching one would retain unrelated subtrees. + +Every other DOM binding in the runtime (`bindTextNode`, `bindAttribute`, the tag +factory's class/style writers) is created with `reactiveBinding(commit, node)`, +which stamps `_errorPhase: "binding"` and the owning node. + +The consequence is only visible on the failure path — the path that matters. A +binding that throws on a **later** run is reported by the notification drain, +which has no other way to know what it was looking at. `reportError()` gives a +node's enclosing `ErrorBoundary` first refusal; with no node, **that branch was +unreachable for every progressive-enhancement binding on the page**, and every +such failure was mislabelled as an effect. + +### Reproducer + +`tests/enhance-binding-error-metadata.test.ts` — five failing cases before the +fix, e.g.: + +```ts +enhance(root, (ctx) => { + ctx.text("@n", () => { + if (n() > 0) throw new Error("text boom"); + return n(); + }); +}); +setN(1); + +expect(reports[0].context.phase).toBe("binding"); // was "effect" +expect(reports[0].context.node).toBe(node); // was undefined +``` + +### Root cause + +`enhance()` predates the `reactiveBinding(commit, ownerNode)` signature and was +never migrated when error metadata was introduced. + +### Fix + +One private helper, `bindNode(el, commit)`, through which all five helpers now +create their bindings: + +```ts +function bindNode(el: HTMLElement, commit: () => void): () => void { + if (isSSR()) return () => {}; + return reactiveBinding(commit, el); +} +``` + +The `isSSR()` guard preserves `effect()`'s existing behaviour exactly — side +effects do not run on the server, so a binding created during SSR stays inert. +Without it this would have been a silent behaviour change on the server. + +Secondary benefit: `reactiveBinding` allocates fewer closures per binding than +`effect()` (no `onCleanup`, no rerun-drain context), which is why `ctx.each` over +64 squares is not slower than the loop it replaces. + +--- + +## DX-002 — no first-class way to bind many existing elements + +**P2 · addressed with a new API** + +### Description + +Not a defect — the imperative loop is correct and complete. But a 64-square +board needs ~6 bindings per square, and the resulting loop buries the one +interesting fact (which square this is) in repetition. The same shape recurs in +tables, keyboards, calendars, timelines, seat maps and legends. + +Confirmed by inspection: `EnhanceContext` had no repeated-binding helper, and +`ctx.refs()` is a query. Nothing in the repository partially solved this. + +### Resolution + +`ctx.each(target, describe)` — see `src/platform/enhance.ts`. Every field of the +returned descriptor is committed **through the existing `ctx.*` helper**, so +there is no second binding path to keep in step: ownership, disposal, +sanitization, write elision and the DX-001 error metadata are the same objects. + +Deliberate limits, so it stays sugar rather than a template language: + +- Six fields (`text`, `attr`, `class`, `show`, `on`, `cleanup`) — exactly what + the context already supports per element. No properties and no styles, because + `EnhanceContext` has no `prop`/`style` helper to delegate to; adding them here + would have made `each` a superset of the API it is shorthand for. +- No expression parsing, no interpolation, no `eval` — CSP-safe by construction. +- The callback may return nothing and wire the element imperatively instead; + `model`, listener options and nested enhancement stay where they were. +- Descriptor mistakes throw in development, naming the index and the key, inside + `setup` — so `enhance`'s transaction rolls the whole thing back. + +Measured cost (`bench/islands-dx.mjs`, 64 squares × 4 bindings): **+9.1% at +setup in production mode**, zero at update time, because the bindings it creates +are the ordinary bindings. Documented as ergonomic sugar, not a mandate. + +--- + +## DX-003 — external mutable state had no documented pattern + +**P1 · addressed with a new primitive** + +### Description + +`chess.js` owns its state internally. The application threaded a revision +signal: + +```ts +const [revision, setRevision] = signal(0); +ctx.text("@status", () => { revision(); return status(game); }); +game.move(...); setRevision((v) => v + 1); +``` + +This works, and is the correct mechanism. Three problems with it as the official +answer: the number is meaningless and appears in every getter that has nothing +to do with counting; nothing names the pattern, so every application reinvents +it; and there was no guidance on granularity, so "one revision for everything" +became the default by accident rather than by choice. + +Audited: no invalidation primitive existed anywhere in `src/`. + +### Resolution + +`external()` in `src/core/signals/signal.ts` — a valueless reactive token with +`track()` and `invalidate()`. + +Implementation is deliberately thin: it wraps a private `signal(0)` rather than +reimplementing against the reactive core, so batching, the drain, +version-based stabilization, duplicate-runtime coordination and devtools all +behave identically to every other source, with **no second implementation of +those invariants to keep in step**. The counter is never handed out — `track()` +returns `void` — so no consumer can come to depend on the number. + +**An `invalidatable(obj)` wrapper was considered and rejected.** It can only +publish changes that go through its own `mutate()`, so a mutation from a library +callback, an internal event handler, or a reference handed elsewhere silently +produces stale UI — the exact failure the explicit call site makes impossible to +overlook. One composable primitive beats two overlapping ones. + +Granularity guidance — four architectures compared on complexity, runtime cost, +memory, granularity and integration effort, with measurements and profiling +technique — is in `docs/architecture/external-state.md`. + +--- + +## DX-004 — `enhance()` + `mount()` composition worked, but was untested and undocumented + +**P2 · no production change; tests and documentation added** + +### Description + +The brief expected defects here. There were none. Eleven composition tests +(`tests/islands-enhance-mount-composition.test.ts`) were written against the +existing implementation and **all passed on the first run**: + +- the mounted subregion disposes with the island +- removing the island root disposes the mounted subregion +- disposal is idempotent across island, enhancement and mount +- two islands on one page keep separate feature state +- a broken island beside a working one disturbs nothing +- a setup that throws *after* mounting leaves nothing live, and the root is + enhanceable again +- a `mount()` failure goes to the island error pipeline, not to the caller +- a binding inside the mounted region reports with its own node +- remount produces exactly one mounted region, not two stacked +- `mountIslands()` twice without cleanup does not double-mount + +What was missing was the composition pattern itself. Two sharp edges found while +writing the tests, now documented: + +1. **`when()` inserts its branch as a sibling of its anchor.** `mount(() => + when(...), slot)` therefore unmounts the anchor and leaves the branch behind. + Wrapping in an element the mount owns fixes it. This is `when`'s documented + design, not a bug — but it is a trap in the mount-into-a-slot position. +2. **`ctx.show()` and `when()` are not interchangeable.** `show` toggles a node + that exists (enhance side); `when` creates and destroys (mount side). + +`ctx.cleanup(history.unmount)` is the single line that makes the composition +transactional. Registered *before* the risky work, it is also what rolls the +mount back when setup throws later. + +--- + +## DX-005 — a new root-only module is not independently tree-shakeable + +**P2 · found by the probe added in this pass · fixed** + +### Description + +`external()` was first written as its own module, `src/core/signals/external.ts`, +reachable only from the root entry. The build splits `dist/` into shared chunks, +and a module that only one entry point reaches lands in the **index-only** chunk +— which also contains `enhance`, `mountIslands`, `mount`, `each`, `when`, +`store`, `writable`, `ref` and `array`. + +The consequence is invisible from the source tree and only appears through the +packed package: a consumer importing `external` from `"sibujs"` pulled the whole +island runtime. + +### Reproducer + +The `external-only` certification probe added in this pass, bundled against the +packed tarball: + +``` +esbuild external-only 77 KB unexpected subsystems: islands +``` + +Reproduced identically in esbuild, Rollup, Vite and webpack. `core-minimal` +(signal/derived/effect/batch) was clean at 13 KB in the same run, so the cause +was chunk placement rather than a stray import. + +### Root cause + +Chunk placement, not a dependency. A control bundle proved it: `store` and +`writable` — both pre-existing exports in the same chunk — produce the same +77 KB bundle carrying the same island marker. Nothing about `external()` caused +it; defining any new root-only module would have done the same. + +### Fix + +`external()` and `ExternalSource` now live in `src/core/signals/signal.ts`, +which every entry point shares and which is therefore in the small core chunk. +The public API is unchanged — it is exported from `"sibujs"` exactly as before. + +``` +before external + signal from the packaged root 77.1 KB, island runtime present +after external + signal from the packaged root 9.1 KB, island runtime absent +``` + +The reason is recorded in a comment at the definition, because "why is this +primitive in signal.ts" is otherwise an invitation to move it back. +`tests/treeshaking-islands.test.ts` pins the property against `dist/`, not just +against `src/`, since that is the only place the difference was visible. + +--- + +## Observed, pre-existing, NOT fixed — `sibujs/plugins` ships i18n with the router + +**P3 · pre-existing · out of scope** + +The certification's `router-only` probe imports from `sibujs/plugins`, and the +i18n singleton marker is present in the output in all four bundlers. This +predates this work: the probe, the barrel and the expectation are all unchanged +here, and `src/plugins/router.ts` does not import i18n — the two are siblings in +one barrel, so the chunk carries both. + +Fixing it means changing how `plugins.ts` is chunked or split, which affects the +published entry points and the 157 export-map checks. That is a packaging change +with its own risk budget and does not belong in this pass. Recorded so it is not +mistaken for a regression: the tree-shaking column reads **16/20 clean**, and the +four unclean rows are exactly these. + +--- + +## Investigated and found correct — no change made + +| Area | Finding | +|---|---| +| Manual invalidation | No existing primitive; DX-003 added one. Nothing was duplicated. | +| Shared stores between enhanced and mounted regions | Ordinary closure state already works and is per-instance. A store abstraction would have added a way to create the exact global-state bug the closure form avoids. | +| Nested `mount()` inside an enhanced island | Correct today. `dispose()` walks descendants, so removing the island root disposes the mounted subtree; `ctx.cleanup(unmount)` covers the case where the root survives. | +| Accessible dialogs | `createDialogAria` + `createFocusManager` + `machine` are sufficient for a modal in server-rendered markup. `FocusTrap` creates a wrapper element, which suits mounted content rather than enhanced content — documented rather than changed. | +| Island error isolation | Already real lifecycle isolation (ISL-001, prior pass). Re-verified with a broken island beside two live boards, in three browsers. | +| Duplicate activation | `mountIslands()` already skips elements carrying `data-sibu-enhanced="true"`. Verified in-browser under repeated host-navigation cycles. | +| Profiling hook | A development-only effect-execution counter was evaluated and **not added**: it would have to sit on the hot path of every subscriber to be accurate, the six-line manual counter answers the same question with zero production cost, and `sibujs/devtools` already carries the general-purpose hooks. | + +--- + +## Not in scope / deliberately not changed + +- **`EnhanceContext` gained no `prop()` or `style()` helper.** The chess board + drives style through `ctx.attr(el, "style", …)`, which is sanitized. Adding + writers was outside the verified friction. +- **No state-management abstraction, no DOM reconciliation, no VDOM.** `each`'s + keyed reconciliation on the mounted side is pre-existing and untouched. +- **No chess rules in the runtime.** `chess.js` is a devDependency used by the + example only. +- **Browser-support policy unchanged.** The vendored engine is built for the + declared floor; nothing new uses a higher baseline. +- **No package version change**, no publish, no release, no remote state + touched. + +--- + +## Result + +| | Before | After | +|---|---|---| +| Unit test files | 449 | 457 | +| Unit tests | 6 320 (+1 skipped) | 6 399 (+1 skipped) | +| Browser specs | 11 files | 13 files | +| Browser runs (Chromium/Firefox/WebKit) | 309 | 366 (57 added), all passing | +| `tsc` (src) · `tsc` (tests+entries) · `biome` | clean | clean | +| New public API | — | `external()`, `ExternalSource`, `ctx.each()`, `EachBindings`, `EachEventBindings` | +| Bundle delta | — | `external()` +0.06 KB gz over `signal` + `effect`; `each` inside the existing enhance module | diff --git a/docs/interop.md b/docs/interop.md new file mode 100644 index 0000000..a71c0fd --- /dev/null +++ b/docs/interop.md @@ -0,0 +1,285 @@ +# Running SibuJS islands inside someone else's page + +SibuJS does not need to own your page. An island is a `