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 `
` your server (or +your existing framework) rendered, plus a call to `mountIslands()`. Everything +outside that element stays exactly as it was. + +That makes SibuJS adoptable **one widget at a time**: a chess board inside a +component-framework application, a pricing calculator in a CMS template, a +dashboard panel in an admin app that will not be rewritten. + +This guide gives the rules once, framework-neutrally, then shows what they look +like in each host. Two of them are verified by tests in this repository; the +rest are the same nine lines with different names. + +--- + +## The nine rules + +Every host reduces to the same questions. + +### 1. The host owns the island root; SibuJS owns its contents + +Your host framework renders the element carrying `data-sibu-island`. SibuJS +attaches bindings to that element and its descendants and **creates no nodes** +unless you call `mount()` yourself. + +The rule that follows: **no node may have two owners.** Either the host renders +a subtree and SibuJS enhances it, or SibuJS mounts into an empty container the +host promises not to touch. Never both. + +### 2. The host may remove the root — as long as you dispose first + +If the host is going to unmount, replace or re-render the island root, call the +disposer **before** it does. After disposal the markup is inert and ownerless, +so the host can do what it likes with it. + +If the host removes the node without telling you, nothing catastrophic happens — +`enhance()` also registers its teardown on the element, so a SibuJS-driven +removal (`dispose(node)`) cleans up. But a host that rips the node out with its +own renderer will not call that, so **keep the disposer and call it**. + +### 3. Call `mountIslands()` after the host has rendered the markup + +Not before. `mountIslands()` scans the DOM it is given; islands that do not +exist yet are not found. In practice that means the host's "the DOM is ready" +hook — a mount effect, a hydration callback, a `DOMContentLoaded`, a +`turbo:load`. + +### 4. Keep the disposer and call it exactly once + +```ts +const dispose = mountIslands(document); +// …later, before the host replaces the markup: +dispose(); +``` + +The disposer cancels pending activation schedulers (`idle`, `visible`, +`interaction`, `media`) *and* disposes every island that already activated. +Calling it twice is safe. + +### 5. Scope the scan when the host owns the rest of the page + +```ts +mountIslands(containerEl); // only islands inside this subtree +``` + +Scoping is what lets two independently-mounted regions coexist without either +one adopting the other's islands. + +### 6. Client-side navigation is "dispose, then mount again" + +A client-side router replaces DOM without a page load. Treat every navigation as +teardown plus setup: + +```ts +onNavigateAway(() => dispose()); +onNavigated(() => { dispose = mountIslands(document); }); +``` + +If your host cannot tell you when it is about to swap the DOM, the fallback is +to dispose and re-mount on *every* navigation event you can observe. Re-mounting +is cheap and idempotent. + +### 7. Duplicate activation is already prevented — but scan anyway + +`mountIslands()` skips any element that currently carries +`data-sibu-enhanced="true"`. So calling it again after the host adds new markup +activates **only the new islands** and leaves the live ones alone. You do not +need to track which islands you have already mounted. + +That is what makes the "just call it again after every render" strategy correct +rather than merely convenient. + +### 8. Keep feature state inside the island setup + +```ts +registerIsland("chess", (ctx) => { + const game = new Chess(); // ✓ one per island element + const changed = external(); + … +}); + +const game = new Chess(); // ✗ shared by every instance on the page +``` + +State created inside the setup is per-instance and dies with the island. Module +state is shared by every instance, survives disposal, and is the single most +common cause of "the second widget on the page behaves strangely". + +### 9. Failures stay inside the island + +An island whose setup throws is reported and left with zero bindings and zero +listeners; its siblings activate normally, and the host page never sees the +exception. You do not need a boundary around `mountIslands()`. + +--- + +## Static HTML, or any classic server-rendered page + +The baseline case — Django, Rails, Laravel, WordPress, Go templates, Hugo, +Eleventy, PHP, ASP.NET. There is no client-side navigation and no host renderer +competing for the DOM, so rules 1–5 are all you need: + +```html +
+ 0 + +
+ + + +``` + +No build step, no bundler, no npm. Emit the markup from whatever templating +language you already use; the `data-ref` attributes are just attributes. + +**Per-host notes:** + +- **Django / Rails / Laravel** — render the island in a partial/template so the + markup and its `data-ref` names live next to each other. Pass server data + through `data-*` attributes on the root and read them with + `ctx.root.dataset`, rather than emitting a ` +``` + +Astro's ` + + diff --git a/examples/chess/vendor/chess.js b/examples/chess/vendor/chess.js new file mode 100644 index 0000000..739f981 --- /dev/null +++ b/examples/chess/vendor/chess.js @@ -0,0 +1,18 @@ +// Generated by scripts/build-example-chess.mjs — do not edit. +// Bundled from chess.js@1.4.0 (BSD-2-Clause). Regenerate with: +// npm run example:chess:build +function es(f){return f!==null?{comment:f,variations:[]}:{variations:[]}}function ts(f,t,e,s,r){let n={move:f,variations:r};return t&&(n.suffix=t),e&&(n.nag=e),s!==null&&(n.comment=s),n}function ss(...f){let[t,...e]=f,s=t;for(let r of e)r!==null&&(s.variations=[r,...r.variations],r.variations=[],s=r);return t}function is(f,t){if(t.marker&&t.marker.comment){let e=t.root;for(;;){let s=e.variations[0];if(!s){e.comment=t.marker.comment;break}e=s}}return{headers:f,root:t.root,result:(t.marker&&t.marker.result)??void 0}}function rs(f,t){function e(){this.constructor=f}e.prototype=t.prototype,f.prototype=new e}function W(f,t,e,s){var r=Error.call(this,f);return Object.setPrototypeOf&&Object.setPrototypeOf(r,W.prototype),r.expected=t,r.found=e,r.location=s,r.name="SyntaxError",r}rs(W,Error);function ce(f,t,e){return e=e||" ",f.length>t?f:(t-=f.length,e+=e.repeat(t),f+e.slice(0,t))}W.prototype.format=function(f){var t="Error: "+this.message;if(this.location){var e=null,s;for(s=0;s `+c+` +`+u+` | +`+n.line+" | "+g+` +`+u+" | "+ce("",r.column-1," ")+ce("",A,"^")}else t+=` + at `+c}return t};W.buildMessage=function(f,t){var e={literal:function(g){return'"'+r(g.text)+'"'},class:function(g){var d=g.parts.map(function(A){return Array.isArray(A)?n(A[0])+"-"+n(A[1]):n(A)});return"["+(g.inverted?"^":"")+d.join("")+"]"},any:function(){return"any character"},end:function(){return"end of input"},other:function(g){return g.description}};function s(g){return g.charCodeAt(0).toString(16).toUpperCase()}function r(g){return g.replace(/\\/g,"\\\\").replace(/"/g,'\\"').replace(/\0/g,"\\0").replace(/\t/g,"\\t").replace(/\n/g,"\\n").replace(/\r/g,"\\r").replace(/[\x00-\x0F]/g,function(d){return"\\x0"+s(d)}).replace(/[\x10-\x1F\x7F-\x9F]/g,function(d){return"\\x"+s(d)})}function n(g){return g.replace(/\\/g,"\\\\").replace(/\]/g,"\\]").replace(/\^/g,"\\^").replace(/-/g,"\\-").replace(/\0/g,"\\0").replace(/\t/g,"\\t").replace(/\n/g,"\\n").replace(/\r/g,"\\r").replace(/[\x00-\x0F]/g,function(d){return"\\x0"+s(d)}).replace(/[\x10-\x1F\x7F-\x9F]/g,function(d){return"\\x"+s(d)})}function c(g){return e[g.type](g)}function h(g){var d=g.map(c),A,p;if(d.sort(),d.length>0){for(A=1,p=1;A=H.length)l=H.length-1;else for(l=i;!H[--l];);for(a=H[l],a={line:a.line,column:a.column};lL&&(L=o,ee=[]),ee.push(i))}function Bt(i,a,l){return new W(W.buildMessage(i,a),i,a,l)}function Ue(){var i,a,l;return i=o,a=jt(),l=Wt(),i=It(a,l),i}function jt(){var i,a,l;for(i=o,a=[],l=Qe();l!==e;)a.push(l),l=Qe();return l=O(),i=xt(a),i}function Qe(){var i,a,l,v,b,k,Q;return _++,i=o,O(),f.charCodeAt(o)===91?(a=c,o++):(a=e,_===0&&m(st)),a!==e?(O(),l=Ht(),l!==e?(O(),f.charCodeAt(o)===34?(v=h,o++):(v=e,_===0&&m(Ie)),v!==e?(b=Gt(),f.charCodeAt(o)===34?(k=h,o++):(k=e,_===0&&m(Ie)),k!==e?(O(),f.charCodeAt(o)===93?(Q=u,o++):(Q=e,_===0&&m(it)),Q!==e?i=Ot(l,b):(o=i,i=e)):(o=i,i=e)):(o=i,i=e)):(o=i,i=e)):(o=i,i=e),_--,i===e&&_===0&&m(tt),i}function Ht(){var i,a,l;if(_++,i=o,a=[],l=f.charAt(o),le.test(l)?o++:(l=e,_===0&&m(fe)),l!==e)for(;l!==e;)a.push(l),l=f.charAt(o),le.test(l)?o++:(l=e,_===0&&m(fe));else a=e;return a!==e?i=f.substring(i,o):i=a,_--,i===e&&(a=e,_===0&&m(rt)),i}function Gt(){var i,a,l;for(_++,i=o,a=[],l=f.charAt(o),$e.test(l)?o++:(l=e,_===0&&m(xe));l!==e;)a.push(l),l=f.charAt(o),$e.test(l)?o++:(l=e,_===0&&m(xe));return i=f.substring(i,o),_--,a=e,_===0&&m(nt),i}function Wt(){var i,a,l;return i=o,a=Be(),O(),l=Xt(),l===e&&(l=null),O(),i=Kt(a,l),i}function Be(){var i,a,l,v;for(i=o,a=he(),a===e&&(a=null),l=[],v=je();v!==e;)l.push(v),v=je();return i=Mt(a,l),i}function je(){var i,a,l,v,b,k,Q,te;if(i=o,O(),Vt(),O(),a=Yt(),a!==e){for(l=zt(),l===e&&(l=null),v=[],b=He();b!==e;)v.push(b),b=He();for(b=O(),k=he(),k===e&&(k=null),Q=[],te=Ge();te!==e;)Q.push(te),te=Ge();i=Lt(a,l,v,k,Q)}else o=i,i=e;return i}function Vt(){var i,a,l,v,b,k;for(_++,i=o,a=[],l=f.charAt(o),J.test(l)?o++:(l=e,_===0&&m(X));l!==e;)a.push(l),l=f.charAt(o),J.test(l)?o++:(l=e,_===0&&m(X));if(f.charCodeAt(o)===46?(l=g,o++):(l=e,_===0&&m(at)),l!==e){for(v=O(),b=[],k=f.charAt(o),ke.test(k)?o++:(k=e,_===0&&m(Oe));k!==e;)b.push(k),k=f.charAt(o),ke.test(k)?o++:(k=e,_===0&&m(Oe));a=[a,l,v,b],i=a}else o=i,i=e;return _--,i===e&&(a=e,_===0&&m(ot)),i}function Yt(){var i,a,l,v,b,k;if(_++,i=o,a=o,f.substr(o,5)===d?(l=d,o+=5):(l=e,_===0&&m(ft)),l===e&&(f.substr(o,3)===A?(l=A,o+=3):(l=e,_===0&&m(ht)),l===e&&(f.substr(o,5)===p?(l=p,o+=5):(l=e,_===0&&m(ct)),l===e&&(f.substr(o,3)===E?(l=E,o+=3):(l=e,_===0&&m(ut)),l===e))))if(l=o,v=f.charAt(o),le.test(v)?o++:(v=e,_===0&&m(fe)),v!==e){if(b=[],k=f.charAt(o),ye.test(k)?o++:(k=e,_===0&&m(Ke)),k!==e)for(;k!==e;)b.push(k),k=f.charAt(o),ye.test(k)?o++:(k=e,_===0&&m(Ke));else b=e;b!==e?(v=[v,b],l=v):(o=l,l=e)}else o=l,l=e;return l!==e?(v=f.charAt(o),et.test(v)?o++:(v=e,_===0&&m(_t)),v===e&&(v=null),l=[l,v],a=l):(o=a,a=e),a!==e?i=f.substring(i,o):i=a,_--,i===e&&(a=e,_===0&&m(lt)),i}function zt(){var i,a,l;for(_++,i=o,a=[],l=f.charAt(o),Te.test(l)?o++:(l=e,_===0&&m(Me));l!==e;)a.push(l),a.length>=2?l=e:(l=f.charAt(o),Te.test(l)?o++:(l=e,_===0&&m(Me)));return a.length<1?(o=i,i=e):i=a,_--,i===e&&(a=e,_===0&&m(pt)),i}function He(){var i,a,l,v,b;if(_++,i=o,O(),f.charCodeAt(o)===36?(a=$,o++):(a=e,_===0&&m(dt)),a!==e){if(l=o,v=[],b=f.charAt(o),J.test(b)?o++:(b=e,_===0&&m(X)),b!==e)for(;b!==e;)v.push(b),b=f.charAt(o),J.test(b)?o++:(b=e,_===0&&m(X));else v=e;v!==e?l=f.substring(l,o):l=v,l!==e?i=Rt(l):(o=i,i=e)}else o=i,i=e;return _--,i===e&&_===0&&m(gt),i}function he(){var i;return i=Zt(),i===e&&(i=Jt()),i}function Zt(){var i,a,l,v,b;if(_++,i=o,f.charCodeAt(o)===123?(a=y,o++):(a=e,_===0&&m(vt)),a!==e){for(l=o,v=[],b=f.charAt(o),Pe.test(b)?o++:(b=e,_===0&&m(Le));b!==e;)v.push(b),b=f.charAt(o),Pe.test(b)?o++:(b=e,_===0&&m(Le));l=f.substring(l,o),f.charCodeAt(o)===125?(v=q,o++):(v=e,_===0&&m(bt)),v!==e?i=qt(l):(o=i,i=e)}else o=i,i=e;return _--,i===e&&(a=e,_===0&&m(mt)),i}function Jt(){var i,a,l,v,b;if(_++,i=o,f.charCodeAt(o)===59?(a=oe,o++):(a=e,_===0&&m(St)),a!==e){for(l=o,v=[],b=f.charAt(o),Ne.test(b)?o++:(b=e,_===0&&m(Re));b!==e;)v.push(b),b=f.charAt(o),Ne.test(b)?o++:(b=e,_===0&&m(Re));l=f.substring(l,o),i=Ft(l)}else o=i,i=e;return _--,i===e&&(a=e,_===0&&m(Et)),i}function Ge(){var i,a,l,v;return _++,i=o,O(),f.charCodeAt(o)===40?(a=ae,o++):(a=e,_===0&&m(At)),a!==e?(l=Be(),l!==e?(O(),f.charCodeAt(o)===41?(v=Je,o++):(v=e,_===0&&m($t)),v!==e?i=Dt(l):(o=i,i=e)):(o=i,i=e)):(o=i,i=e),_--,i===e&&_===0&&m(Ct),i}function Xt(){var i,a,l;return _++,i=o,f.substr(o,3)===Se?(a=Se,o+=3):(a=e,_===0&&m(yt)),a===e&&(f.substr(o,3)===Ce?(a=Ce,o+=3):(a=e,_===0&&m(Tt)),a===e&&(f.substr(o,7)===Ae?(a=Ae,o+=7):(a=e,_===0&&m(Pt)),a===e&&(f.charCodeAt(o)===42?(a=Xe,o++):(a=e,_===0&&m(Nt))))),a!==e?(O(),l=he(),l===e&&(l=null),i=Ut(a,l)):(o=i,i=e),_--,i===e&&(a=e,_===0&&m(kt)),i}function O(){var i,a;for(_++,i=[],a=f.charAt(o),we.test(a)?o++:(a=e,_===0&&m(qe));a!==e;)i.push(a),a=f.charAt(o),we.test(a)?o++:(a=e,_===0&&m(qe));return _--,a=e,_===0&&m(wt),i}if(V=n(),t.peg$library)return{peg$result:V,peg$currPos:o,peg$FAILED:e,peg$maxFailExpected:ee,peg$maxFailPos:L};if(V!==e&&o===f.length)return V;throw V!==e&&o>64n-t)&0xffffffffffffffffn}function We(f,t){return f*t&ie}function os(f){return function(){let t=BigInt(f&ie),e=BigInt(f>>64n&ie),s=We(ue(We(t,5n),7n),9n);return e^=t,t=(ue(t,24n)^e^e<<16n)&ie,e=ue(e,37n),f=e<<64n|t,s}}var ne=os(0xa187eb39cdcaed8f31c4b365b102e01en),as=Array.from({length:2},()=>Array.from({length:6},()=>Array.from({length:128},()=>ne()))),ls=Array.from({length:8},()=>ne()),fs=Array.from({length:16},()=>ne()),_e=ne(),x="w",K="b",T="p",ve="n",re="b",z="r",U="q",P="k",pe="rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1",G=class{color;from;to;piece;captured;promotion;flags;san;lan;before;after;constructor(t,e){let{color:s,piece:r,from:n,to:c,flags:h,captured:u,promotion:g}=e,d=w(n),A=w(c);this.color=s,this.piece=r,this.from=d,this.to=A,this.san=t._moveToSan(e,t._moves({legal:!0})),this.lan=d+A,this.before=t.fen(),t._makeMove(e),this.after=t.fen(),t._undoMove(),this.flags="";for(let p in C)C[p]&h&&(this.flags+=B[p]);u&&(this.captured=u),g&&(this.promotion=g,this.lan+=g)}isCapture(){return this.flags.indexOf(B.CAPTURE)>-1}isPromotion(){return this.flags.indexOf(B.PROMOTION)>-1}isEnPassant(){return this.flags.indexOf(B.EP_CAPTURE)>-1}isKingsideCastle(){return this.flags.indexOf(B.KSIDE_CASTLE)>-1}isQueensideCastle(){return this.flags.indexOf(B.QSIDE_CASTLE)>-1}isBigPawn(){return this.flags.indexOf(B.BIG_PAWN)>-1}},I=-1,B={NORMAL:"n",CAPTURE:"c",BIG_PAWN:"b",EP_CAPTURE:"e",PROMOTION:"p",KSIDE_CASTLE:"k",QSIDE_CASTLE:"q",NULL_MOVE:"-"},hs=["a8","b8","c8","d8","e8","f8","g8","h8","a7","b7","c7","d7","e7","f7","g7","h7","a6","b6","c6","d6","e6","f6","g6","h6","a5","b5","c5","d5","e5","f5","g5","h5","a4","b4","c4","d4","e4","f4","g4","h4","a3","b3","c3","d3","e3","f3","g3","h3","a2","b2","c2","d2","e2","f2","g2","h2","a1","b1","c1","d1","e1","f1","g1","h1"],C={NORMAL:1,CAPTURE:2,BIG_PAWN:4,EP_CAPTURE:8,PROMOTION:16,KSIDE_CASTLE:32,QSIDE_CASTLE:64,NULL_MOVE:128},be={Event:"?",Site:"?",Date:"????.??.??",Round:"?",White:"?",Black:"?",Result:"*"},cs={WhiteTitle:null,BlackTitle:null,WhiteElo:null,BlackElo:null,WhiteUSCF:null,BlackUSCF:null,WhiteNA:null,BlackNA:null,WhiteType:null,BlackType:null,EventDate:null,EventSponsor:null,Section:null,Stage:null,Board:null,Opening:null,Variation:null,SubVariation:null,ECO:null,NIC:null,Time:null,UTCTime:null,UTCDate:null,TimeControl:null,SetUp:null,FEN:null,Termination:null,Annotator:null,Mode:null,PlyCount:null},us={...be,...cs},S={a8:0,b8:1,c8:2,d8:3,e8:4,f8:5,g8:6,h8:7,a7:16,b7:17,c7:18,d7:19,e7:20,f7:21,g7:22,h7:23,a6:32,b6:33,c6:34,d6:35,e6:36,f6:37,g6:38,h6:39,a5:48,b5:49,c5:50,d5:51,e5:52,f5:53,g5:54,h5:55,a4:64,b4:65,c4:66,d4:67,e4:68,f4:69,g4:70,h4:71,a3:80,b3:81,c3:82,d3:83,e3:84,f3:85,g3:86,h3:87,a2:96,b2:97,c2:98,d2:99,e2:100,f2:101,g2:102,h2:103,a1:112,b1:113,c1:114,d1:115,e1:116,f1:117,g1:118,h1:119},ge={b:[16,32,17,15],w:[-16,-32,-17,-15]},Ve={n:[-18,-33,-31,-14,18,33,31,14],b:[-17,-15,17,15],r:[-16,1,16,-1],q:[-17,-16,-15,1,17,16,15,-1],k:[-17,-16,-15,1,17,16,15,-1]},_s=[20,0,0,0,0,0,0,24,0,0,0,0,0,0,20,0,0,20,0,0,0,0,0,24,0,0,0,0,0,20,0,0,0,0,20,0,0,0,0,24,0,0,0,0,20,0,0,0,0,0,0,20,0,0,0,24,0,0,0,20,0,0,0,0,0,0,0,0,20,0,0,24,0,0,20,0,0,0,0,0,0,0,0,0,0,20,2,24,2,20,0,0,0,0,0,0,0,0,0,0,0,2,53,56,53,2,0,0,0,0,0,0,24,24,24,24,24,24,56,0,56,24,24,24,24,24,24,0,0,0,0,0,0,2,53,56,53,2,0,0,0,0,0,0,0,0,0,0,0,20,2,24,2,20,0,0,0,0,0,0,0,0,0,0,20,0,0,24,0,0,20,0,0,0,0,0,0,0,0,20,0,0,0,24,0,0,0,20,0,0,0,0,0,0,20,0,0,0,0,24,0,0,0,0,20,0,0,0,0,20,0,0,0,0,0,24,0,0,0,0,0,20,0,0,20,0,0,0,0,0,0,24,0,0,0,0,0,0,20],ps=[17,0,0,0,0,0,0,16,0,0,0,0,0,0,15,0,0,17,0,0,0,0,0,16,0,0,0,0,0,15,0,0,0,0,17,0,0,0,0,16,0,0,0,0,15,0,0,0,0,0,0,17,0,0,0,16,0,0,0,15,0,0,0,0,0,0,0,0,17,0,0,16,0,0,15,0,0,0,0,0,0,0,0,0,0,17,0,16,0,15,0,0,0,0,0,0,0,0,0,0,0,0,17,16,15,0,0,0,0,0,0,0,1,1,1,1,1,1,1,0,-1,-1,-1,-1,-1,-1,-1,0,0,0,0,0,0,0,-15,-16,-17,0,0,0,0,0,0,0,0,0,0,0,0,-15,0,-16,0,-17,0,0,0,0,0,0,0,0,0,0,-15,0,0,-16,0,0,-17,0,0,0,0,0,0,0,0,-15,0,0,0,-16,0,0,0,-17,0,0,0,0,0,0,-15,0,0,0,0,-16,0,0,0,0,-17,0,0,0,0,-15,0,0,0,0,0,-16,0,0,0,0,0,-17,0,0,-15,0,0,0,0,0,0,-16,0,0,0,0,0,0,-17],gs={p:1,n:2,b:4,r:8,q:16,k:32},ds="pnbrqkPNBRQK",Ye=[ve,re,z,U],ms=7,vs=6,bs=1,Es=0,se={[P]:C.KSIDE_CASTLE,[U]:C.QSIDE_CASTLE},F={w:[{square:S.a1,flag:C.QSIDE_CASTLE},{square:S.h1,flag:C.KSIDE_CASTLE}],b:[{square:S.a8,flag:C.QSIDE_CASTLE},{square:S.h8,flag:C.KSIDE_CASTLE}]},Ss={b:bs,w:vs},de="--";function j(f){return f>>4}function Z(f){return f&15}function Ze(f){return"0123456789".indexOf(f)!==-1}function w(f){let t=Z(f),e=j(f);return"abcdefgh".substring(t,t+1)+"87654321".substring(e,e+1)}function Y(f){return f===x?K:x}function Cs(f){let t=f.split(/\s+/);if(t.length!==6)return{ok:!1,error:"Invalid FEN: must contain six space-delimited fields"};let e=parseInt(t[5],10);if(isNaN(e)||e<=0)return{ok:!1,error:"Invalid FEN: move number must be a positive integer"};let s=parseInt(t[4],10);if(isNaN(s)||s<0)return{ok:!1,error:"Invalid FEN: half move counter number must be a non-negative integer"};if(!/^(-|[abcdefgh][36])$/.test(t[3]))return{ok:!1,error:"Invalid FEN: en-passant square is invalid"};if(/[^kKqQ-]/.test(t[2]))return{ok:!1,error:"Invalid FEN: castling availability is invalid"};if(!/^(w|b)$/.test(t[1]))return{ok:!1,error:"Invalid FEN: side-to-move is invalid"};let r=t[0].split("/");if(r.length!==8)return{ok:!1,error:"Invalid FEN: piece data does not contain 8 '/'-delimited rows"};for(let c=0;c1)return{ok:!1,error:`Invalid FEN: too many ${c} kings`}}return Array.from(r[0]+r[7]).some(c=>c.toUpperCase()==="P")?{ok:!1,error:"Invalid FEN: some pawns are on the edge rows"}:{ok:!0}}function As(f,t){let e=f.from,s=f.to,r=f.piece,n=0,c=0,h=0;for(let u=0,g=t.length;u0?c>0&&h>0?w(e):h>0?w(e).charAt(1):w(e).charAt(0):""}function D(f,t,e,s,r,n=void 0,c=C.NORMAL){let h=j(s);if(r===T&&(h===ms||h===Es))for(let u=0;u="a"&&t<="h"?f.match(/[a-h]\d.*[a-h]\d/)?void 0:T:(t=t.toLowerCase(),t==="o"?P:t)}function me(f){return f.replace(/=/,"").replace(/[+#]?[?!]*$/,"")}var Ee=class{_board=new Array(128);_turn=x;_header={};_kings={w:I,b:I};_epSquare=-1;_halfMoves=0;_moveNumber=0;_history=[];_comments={};_castling={w:0,b:0};_hash=0n;_positionCount=new Map;constructor(t=pe,{skipValidation:e=!1}={}){this.load(t,{skipValidation:e})}clear({preserveHeaders:t=!1}={}){this._board=new Array(128),this._kings={w:I,b:I},this._turn=x,this._castling={w:0,b:0},this._epSquare=I,this._halfMoves=0,this._moveNumber=1,this._history=[],this._comments={},this._header=t?this._header:{...us},this._hash=this._computeHash(),this._positionCount=new Map,this._header.SetUp=null,this._header.FEN=null}load(t,{skipValidation:e=!1,preserveHeaders:s=!1}={}){let r=t.split(/\s+/);if(r.length>=2&&r.length<6){let h=["-","-","0","1"];t=r.concat(h.slice(-(6-r.length))).join(" ")}if(r=t.split(/\s+/),!e){let{ok:h,error:u}=Cs(t);if(!h)throw new Error(u)}let n=r[0],c=0;this.clear({preserveHeaders:s});for(let h=0;h-1&&(this._castling.w|=C.KSIDE_CASTLE),r[2].indexOf("Q")>-1&&(this._castling.w|=C.QSIDE_CASTLE),r[2].indexOf("k")>-1&&(this._castling.b|=C.KSIDE_CASTLE),r[2].indexOf("q")>-1&&(this._castling.b|=C.QSIDE_CASTLE),this._epSquare=r[3]==="-"?I:S[r[3]],this._halfMoves=parseInt(r[4],10),this._moveNumber=parseInt(r[5],10),this._hash=this._computeHash(),this._updateSetup(t),this._incPositionCount()}fen({forceEnpassantSquare:t=!1}={}){let e=0,s="";for(let c=S.a8;c<=S.h1;c++){if(this._board[c]){e>0&&(s+=e,e=0);let{color:h,type:u}=this._board[c];s+=h===x?u.toUpperCase():u.toLowerCase()}else e++;c+1&136&&(e>0&&(s+=e),c!==S.h1&&(s+="/"),e=0,c+=8)}let r="";this._castling[x]&C.KSIDE_CASTLE&&(r+="K"),this._castling[x]&C.QSIDE_CASTLE&&(r+="Q"),this._castling[K]&C.KSIDE_CASTLE&&(r+="k"),this._castling[K]&C.QSIDE_CASTLE&&(r+="q"),r=r||"-";let n="-";if(this._epSquare!==I)if(t)n=w(this._epSquare);else{let c=this._epSquare+(this._turn===x?16:-16),h=[c+1,c-1];for(let u of h){if(u&136)continue;let g=this._turn;if(this._board[u]?.color===g&&this._board[u]?.type===T){this._makeMove({color:g,from:u,to:this._epSquare,piece:T,captured:T,flags:C.EP_CAPTURE});let d=!this._isKingAttacked(g);if(this._undoMove(),d){n=w(this._epSquare);break}}}}return[s,this._turn,r,n,this._halfMoves,this._moveNumber].join(" ")}_pieceKey(t){if(!this._board[t])return 0n;let{color:e,type:s}=this._board[t],r={w:0,b:1}[e],n={p:0,n:1,b:2,r:3,q:4,k:5}[s];return as[r][n][t]}_epKey(){return this._epSquare===I?0n:ls[this._epSquare&7]}_castlingKey(){let t=this._castling.w>>5|this._castling.b>>3;return fs[t]}_computeHash(){let t=0n;for(let e=S.a8;e<=S.h1;e++){if(e&136){e+=7;continue}this._board[e]&&(t^=this._pieceKey(e))}return t^=this._epKey(),t^=this._castlingKey(),this._turn==="b"&&(t^=_e),t}_updateSetup(t){this._history.length>0||(t!==pe?(this._header.SetUp="1",this._header.FEN=t):(this._header.SetUp=null,this._header.FEN=null))}reset(){this.load(pe)}get(t){return this._board[S[t]]}findPiece(t){let e=[];for(let s=S.a8;s<=S.h1;s++){if(s&136){s+=7;continue}!this._board[s]||this._board[s]?.color!==t.color||this._board[s].color===t.color&&this._board[s].type===t.type&&e.push(w(s))}return e}put({type:t,color:e},s){return this._put({type:t,color:e},s)?(this._updateCastlingRights(),this._updateEnPassantSquare(),this._updateSetup(this.fen()),!0):!1}_set(t,e){this._hash^=this._pieceKey(t),this._board[t]=e,this._hash^=this._pieceKey(t)}_put({type:t,color:e},s){if(ds.indexOf(t.toLowerCase())===-1||!(s in S))return!1;let r=S[s];if(t==P&&!(this._kings[e]==I||this._kings[e]==r))return!1;let n=this._board[r];return n&&n.type===P&&(this._kings[n.color]=I),this._set(r,{type:t,color:e}),t===P&&(this._kings[e]=r),!0}_clear(t){this._hash^=this._pieceKey(t),delete this._board[t]}remove(t){let e=this.get(t);return this._clear(S[t]),e&&e.type===P&&(this._kings[e.color]=I),this._updateCastlingRights(),this._updateEnPassantSquare(),this._updateSetup(this.fen()),e}_updateCastlingRights(){this._hash^=this._castlingKey();let t=this._board[S.e1]?.type===P&&this._board[S.e1]?.color===x,e=this._board[S.e8]?.type===P&&this._board[S.e8]?.color===K;(!t||this._board[S.a1]?.type!==z||this._board[S.a1]?.color!==x)&&(this._castling.w&=-65),(!t||this._board[S.h1]?.type!==z||this._board[S.h1]?.color!==x)&&(this._castling.w&=-33),(!e||this._board[S.a8]?.type!==z||this._board[S.a8]?.color!==K)&&(this._castling.b&=-65),(!e||this._board[S.h8]?.type!==z||this._board[S.h8]?.color!==K)&&(this._castling.b&=-33),this._hash^=this._castlingKey()}_updateEnPassantSquare(){if(this._epSquare===I)return;let t=this._epSquare+(this._turn===x?-16:16),e=this._epSquare+(this._turn===x?16:-16),s=[e+1,e-1];if(this._board[t]!==null||this._board[this._epSquare]!==null||this._board[e]?.color!==Y(this._turn)||this._board[e]?.type!==T){this._hash^=this._epKey(),this._epSquare=I;return}let r=n=>!(n&136)&&this._board[n]?.color===this._turn&&this._board[n]?.type===T;s.some(r)||(this._hash^=this._epKey(),this._epSquare=I)}_attacked(t,e,s){let r=[];for(let n=S.a8;n<=S.h1;n++){if(n&136){n+=7;continue}if(this._board[n]===void 0||this._board[n].color!==t)continue;let c=this._board[n],h=n-e;if(h===0)continue;let u=h+119;if(_s[u]&gs[c.type]){if(c.type===T){if(h>0&&c.color===x||h<=0&&c.color===K)if(s)r.push(w(n));else return!0;continue}if(c.type==="n"||c.type==="k")if(s){r.push(w(n));continue}else return!0;let g=ps[u],d=n+g,A=!1;for(;d!==e;){if(this._board[d]!=null){A=!0;break}d+=g}if(!A)if(s){r.push(w(n));continue}else return!0}}return s?r:!1}attackers(t,e){return e?this._attacked(e,S[t],!0):this._attacked(this._turn,S[t],!0)}_isKingAttacked(t){let e=this._kings[t];return e===-1?!1:this._attacked(Y(t),e)}hash(){return this._hash.toString(16)}isAttacked(t,e){return this._attacked(e,S[t])}isCheck(){return this._isKingAttacked(this._turn)}inCheck(){return this.isCheck()}isCheckmate(){return this.isCheck()&&this._moves().length===0}isStalemate(){return!this.isCheck()&&this._moves().length===0}isInsufficientMaterial(){let t={b:0,n:0,r:0,q:0,k:0,p:0},e=[],s=0,r=0;for(let n=S.a8;n<=S.h1;n++){if(r=(r+1)%2,n&136){n+=7;continue}let c=this._board[n];c&&(t[c.type]=c.type in t?t[c.type]+1:1,c.type===re&&e.push(r),s++)}if(s===2)return!0;if(s===3&&(t[re]===1||t[ve]===1))return!0;if(s===t[re]+2){let n=0,c=e.length;for(let h=0;h=3}isDrawByFiftyMoves(){return this._halfMoves>=100}isDraw(){return this.isDrawByFiftyMoves()||this.isStalemate()||this.isInsufficientMaterial()||this.isThreefoldRepetition()}isGameOver(){return this.isCheckmate()||this.isDraw()}moves({verbose:t=!1,square:e=void 0,piece:s=void 0}={}){let r=this._moves({square:e,piece:s});return t?r.map(n=>new G(this,n)):r.map(n=>this._moveToSan(n,r))}_moves({legal:t=!0,piece:e=void 0,square:s=void 0}={}){let r=s?s.toLowerCase():void 0,n=e?.toLowerCase(),c=[],h=this._turn,u=Y(h),g=S.a8,d=S.h1,A=!1;if(r)if(r in S)g=d=S[r],A=!0;else return[];for(let E=g;E<=d;E++){if(E&136){E+=7;continue}if(!this._board[E]||this._board[E].color===u)continue;let{type:$}=this._board[E],y;if($===T){if(n&&n!==$)continue;y=E+ge[h][0],this._board[y]||(D(c,h,E,y,T),y=E+ge[h][1],Ss[h]===j(E)&&!this._board[y]&&D(c,h,E,y,T,void 0,C.BIG_PAWN));for(let q=2;q<4;q++)y=E+ge[h][q],!(y&136)&&(this._board[y]?.color===u?D(c,h,E,y,T,this._board[y].type,C.CAPTURE):y===this._epSquare&&D(c,h,E,y,T,T,C.EP_CAPTURE))}else{if(n&&n!==$)continue;for(let q=0,oe=Ve[$].length;q{let E=this._comments[this.fen()];if(typeof E<"u"){let $=p.length>0?" ":"";p=`${p}${$}{${E}}`}return p},c=[];for(;this._history.length>0;)c.push(this._undoMove());let h=[],u="";for(c.length===0&&h.push(n(""));c.length>0;){u=n(u);let p=c.pop();if(!p)break;if(!this._history.length&&p.color==="b"){let E=`${this._moveNumber}. ...`;u=u?`${u} ${E}`:E}else p.color==="w"&&(u.length&&h.push(u),u=this._moveNumber+".");u=u+" "+this._moveToSan(p,this._moves({legal:!0})),this._makeMove(p)}if(u.length&&h.push(n(u)),h.push(this._header.Result||"*"),e===0)return s.join("")+h.join(" ");let g=function(){return s.length>0&&s[s.length-1]===" "?(s.pop(),!0):!1},d=function(p,E){for(let $ of E.split(" "))if($){if(p+$.length>e){for(;g();)p--;s.push(t),p=0}s.push($),p+=$.length,s.push(" "),p++}return g()&&p--,p},A=0;for(let p=0;pe&&h[p].includes("{")){A=d(A,h[p]);continue}A+h[p].length>e&&p!==0?(s[s.length-1]===" "&&s.pop(),s.push(t),A=0):p!==0&&(s.push(" "),A++),s.push(h[p]),A+=h[p].length}return s.join("")}header(...t){for(let e=0;e0?s+=this.perft(t-1):s++),this._undoMove();return s}setTurn(t){return this._turn==t?!1:(this.move("--"),!0)}turn(){return this._turn}board(){let t=[],e=[];for(let s=S.a8;s<=S.h1;s++)this._board[s]==null?e.push(null):e.push({square:w(s),type:this._board[s].type,color:this._board[s].color}),s+1&136&&(t.push(e),e=[],s+=8);return t}squareColor(t){if(t in S){let e=S[t];return(j(e)+Z(e))%2===0?"light":"dark"}return null}history({verbose:t=!1}={}){let e=[],s=[];for(;this._history.length>0;)e.push(this._undoMove());for(;;){let r=e.pop();if(!r)break;t?s.push(new G(this,r)):s.push(this._moveToSan(r,this._moves())),this._makeMove(r)}return s}_getPositionCount(t){return this._positionCount.get(t)??0}_incPositionCount(){this._positionCount.set(this._hash,(this._positionCount.get(this._hash)??0)+1)}_decPositionCount(t){let e=this._positionCount.get(t)??0;e===1?this._positionCount.delete(t):this._positionCount.set(t,e-1)}_pruneComments(){let t=[],e={},s=r=>{r in this._comments&&(e[r]=this._comments[r])};for(;this._history.length>0;)t.push(this._undoMove());for(s(this.fen());;){let r=t.pop();if(!r)break;this._makeMove(r),s(this.fen())}this._comments=e}getComment(){return this._comments[this.fen()]}setComment(t){this._comments[this.fen()]=t.replace("{","[").replace("}","]")}deleteComment(){return this.removeComment()}removeComment(){let t=this._comments[this.fen()];return delete this._comments[this.fen()],t}getComments(){return this._pruneComments(),Object.keys(this._comments).map(t=>({fen:t,comment:this._comments[t]}))}deleteComments(){return this.removeComments()}removeComments(){return this._pruneComments(),Object.keys(this._comments).map(t=>{let e=this._comments[t];return delete this._comments[t],{fen:t,comment:e}})}setCastlingRights(t,e){for(let r of[P,U])e[r]!==void 0&&(e[r]?this._castling[t]|=se[r]:this._castling[t]&=~se[r]);this._updateCastlingRights();let s=this.getCastlingRights(t);return(e[P]===void 0||e[P]===s[P])&&(e[U]===void 0||e[U]===s[U])}getCastlingRights(t){return{[P]:(this._castling[t]&se[P])!==0,[U]:(this._castling[t]&se[U])!==0}}moveNumber(){return this._moveNumber}};export{Ee as Chess,hs as SQUARES}; diff --git a/examples/interop-host.html b/examples/interop-host.html new file mode 100644 index 0000000..f7f87dc --- /dev/null +++ b/examples/interop-host.html @@ -0,0 +1,129 @@ + + + + + + SibuJS islands inside a host framework + + + +

Islands inside a page someone else owns

+

+ #app below is owned by a "host framework" — a deliberately tiny + client-side router that re-renders the whole region on navigation, the way a + component framework does. SibuJS never touches anything outside the island + root, and the host never renders inside it. +

+

+ The counter at the top right of the page is a SECOND island, mounted from a + different scope, to prove the two disposers do not adopt each other's work. +

+ +
+

Sidebar island (never re-rendered by the host): 0

+ +
+ + + +
+ +

+ Activations: 0 · + Live cleanups outstanding: 0 +

+ + + + diff --git a/fixtures/rc/probes/external-only.js b/fixtures/rc/probes/external-only.js new file mode 100644 index 0000000..06d4129 --- /dev/null +++ b/fixtures/rc/probes/external-only.js @@ -0,0 +1,31 @@ +// Tree-shaking + `sideEffects: false` probe: EXTERNAL-STATE PRIMITIVE ONLY. +// +// `external()` is the seam for state SibuJS does not own. A page that uses it +// for a canvas, an editor or a socket cache must not be charged for the island +// runtime, the router, the data layer or anything else — so this probe imports +// it beside `signal`/`effect` and nothing more, and the matrix asserts that no +// other subsystem's marker survives into the bundle. +import { effect, external, signal } from "sibujs"; + +// A deliberately opaque mutable object — the thing SibuJS cannot observe. +const engine = { moves: 0 }; +const changed = external(); + +let seen = -1; +const stop = effect(() => { + changed.track(); + seen = engine.moves; +}); + +engine.moves = 7; +changed.invalidate(); + +const [n, setN] = signal(0); +setN(1); +stop(); +engine.moves = 99; +changed.invalidate(); // must not reach the disposed effect + +const ok = seen === 7 && n() === 1; +console.log(`SIBU_PROBE external-only ${ok ? "OK" : "BROKEN"} seen=${seen} n=${n()}`); +if (!ok) process.exit?.(1); diff --git a/fixtures/rc/probes/islands-only.js b/fixtures/rc/probes/islands-only.js new file mode 100644 index 0000000..d03b519 --- /dev/null +++ b/fixtures/rc/probes/islands-only.js @@ -0,0 +1,26 @@ +// Tree-shaking + `sideEffects: false` probe: ISLANDS / ENHANCEMENT ONLY. +// +// The progressive-enhancement entry point is the one people put on a page that +// is otherwise not a SibuJS app. It must bring the island registry and nothing +// else — no router, no query cache, no i18n, no dialog stack, no wasm loader. +// +// It runs without a DOM: `mountIslands(null)` is the documented no-op, which is +// also the smoke test that the module initialised at all. +import { enhance, external, mountIslands, registerIsland, signal } from "sibujs"; + +registerIsland("probe", (ctx) => { + const [n] = signal(0); + ctx.text("@n", () => n()); +}); + +const dispose = mountIslands(null); +dispose(); +dispose(); + +const ok = + typeof enhance === "function" && + typeof external === "function" && + typeof registerIsland === "function" && + typeof dispose === "function"; +console.log(`SIBU_PROBE islands-only ${ok ? "OK" : "BROKEN"}`); +if (!ok) process.exit?.(1); diff --git a/index.ts b/index.ts index ca0eb9f..4e2702c 100644 --- a/index.ts +++ b/index.ts @@ -71,7 +71,9 @@ export * from "./src/core/signals/deepSignal"; export * from "./src/core/signals/derived"; export * from "./src/core/signals/effect"; export * from "./src/core/signals/ref"; -// Signals — state & reactivity +// Signals — state & reactivity. `signal.ts` also carries `external()`: reactive +// integration with state SibuJS does not own (domain engines, media elements, +// editors, sockets), where invalidation is explicit rather than pretended. export * from "./src/core/signals/signal"; export * from "./src/core/signals/store"; export * from "./src/core/signals/watch"; diff --git a/package-lock.json b/package-lock.json index e1f2787..84b076a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sibujs", - "version": "4.0.1", + "version": "4.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sibujs", - "version": "4.0.1", + "version": "4.1.0", "license": "MIT", "devDependencies": { "@biomejs/biome": "2.4.7", @@ -14,6 +14,8 @@ "@types/node": "^22.20.1", "@vitest/coverage-v8": "^3.1.3", "@vitest/ui": "^3.1.3", + "chess.js": "^1.4.0", + "esbuild": "^0.27.7", "fake-indexeddb": "^6.2.5", "jsdom": "^26.1.0", "tsup": "^8.5.1", @@ -1643,6 +1645,13 @@ "node": ">= 16" } }, + "node_modules/chess.js": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/chess.js/-/chess.js-1.4.0.tgz", + "integrity": "sha512-BBJgrrtKQOzFLonR0l+k64A98NLemPwNsCskwb+29bRwobUa4iTm51E1kwGPbWXAcfdDa18nad6vpPPKPWarqw==", + "dev": true, + "license": "BSD-2-Clause" + }, "node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", diff --git a/package.json b/package.json index 1976493..aba45ca 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sibujs", - "version": "4.0.1", + "version": "4.1.0", "description": "A lightweight, function-based frontend framework that combines the best of React, Svelte, and Vue — with zero VDOM and maximum simplicity. Designed for developers who want fine-grained reactivity and full control without compilation or magic.", "keywords": [ "frontend", @@ -41,7 +41,11 @@ "typecheck:tests": "tsc -p tsconfig.test.json", "certify:rc": "node scripts/certify/run.mjs", "build": "tsup index.ts data.ts browser.ts patterns.ts motion.ts ui.ts widgets.ts ssr.ts devtools.ts performance.ts ecosystem.ts plugins.ts build.ts testing.ts extras.ts --dts --format esm,cjs --out-dir dist --clean && tsup cdn.ts --format iife --globalName Sibu --out-dir dist --no-dts --minify", + "example:chess:build": "node scripts/build-example-chess.mjs", + "example:serve": "node tests-browser/server.mjs", "bench": "node bench.mjs", + "bench:islands-dx": "node bench/islands-dx.mjs", + "bench:islands-size": "node bench/islands-size.mjs", "bench:save": "node bench.mjs --save", "bench:check": "node bench.mjs --compare", "publish:npm": "node publish.mjs", @@ -146,6 +150,8 @@ "@types/node": "^22.20.1", "@vitest/coverage-v8": "^3.1.3", "@vitest/ui": "^3.1.3", + "chess.js": "^1.4.0", + "esbuild": "^0.27.7", "fake-indexeddb": "^6.2.5", "jsdom": "^26.1.0", "tsup": "^8.5.1", diff --git a/scripts/build-example-chess.mjs b/scripts/build-example-chess.mjs new file mode 100644 index 0000000..99cc1d4 --- /dev/null +++ b/scripts/build-example-chess.mjs @@ -0,0 +1,58 @@ +// --------------------------------------------------------------------------- +// Build the chess reference example's vendored domain engine. +// +// node scripts/build-example-chess.mjs (npm run example:chess:build) +// +// The example needs `chess.js` in the browser, and the example is deliberately +// build-free for the SibuJS half: the page loads `dist/*.js` directly and the +// island source is the file you read. So the ONE thing that needs bundling is +// the third-party engine, and it is bundled ahead of time into +// `examples/chess/vendor/chess.js` — a committed artifact with a documented +// regeneration command, not a hidden step a reader has to discover. +// +// `chess.js` is a devDependency of this package and a dependency of the +// EXAMPLE. It is never imported from `src/`, never reachable from any package +// entry point, and never present in the published tarball (`files` ships +// `dist`, `README.md` and `LICENSE` only) — the architectural boundary the +// example is there to demonstrate is also a packaging boundary. +// --------------------------------------------------------------------------- + +import { build } from "esbuild"; +import { createRequire } from "node:module"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const require = createRequire(import.meta.url); +const outFile = resolve(root, "examples/chess/vendor/chess.js"); + +const pkg = JSON.parse(readFileSync(require.resolve("chess.js/package.json"), "utf8")); + +mkdirSync(dirname(outFile), { recursive: true }); + +const result = await build({ + stdin: { + contents: `export { Chess, SQUARES } from "chess.js";`, + resolveDir: root, + loader: "js", + }, + bundle: true, + format: "esm", + // Matches the package's declared browser floor (see package.json + // "browserslist"): the vendored engine must not raise it. + target: ["chrome93", "firefox92", "safari15.4", "edge93"], + minify: true, + legalComments: "none", + write: false, +}); + +const banner = `// Generated by scripts/build-example-chess.mjs — do not edit. +// Bundled from chess.js@${pkg.version} (${pkg.license}). Regenerate with: +// npm run example:chess:build +`; + +writeFileSync(outFile, banner + result.outputFiles[0].text, "utf8"); + +const bytes = Buffer.byteLength(banner + result.outputFiles[0].text); +console.log(`examples/chess/vendor/chess.js ${(bytes / 1024).toFixed(1)} KB (chess.js@${pkg.version})`); diff --git a/scripts/certify/bundler-matrix.mjs b/scripts/certify/bundler-matrix.mjs index b86e99e..aaca274 100644 --- a/scripts/certify/bundler-matrix.mjs +++ b/scripts/certify/bundler-matrix.mjs @@ -32,7 +32,7 @@ if (!workdirArg || !tarballArg) { const WORK = resolve(workdirArg); const TARBALL = resolve(tarballArg); -const PROBES = ["core-minimal", "data-only", "router-only"]; +const PROBES = ["core-minimal", "data-only", "router-only", "external-only", "islands-only"]; // Markers proving a subsystem is present in bundled output. Each is a string // that only that subsystem emits, checked against the *minified* production @@ -61,6 +61,12 @@ const EXPECTED_SUBSYSTEMS = { "core-minimal": [], "data-only": ["query"], "router-only": ["router"], + // `external()` is a core signal primitive. A page integrating a canvas or an + // editor with it must not be charged for the island runtime. + "external-only": [], + // The progressive-enhancement entry point brings the island registry and + // nothing else — this is the claim "adopt one widget at a time" rests on. + "islands-only": ["islands"], }; const run = (cmd, args, opts = {}) => diff --git a/src/core/signals/signal.ts b/src/core/signals/signal.ts index d934cfa..cb77367 100644 --- a/src/core/signals/signal.ts +++ b/src/core/signals/signal.ts @@ -162,3 +162,117 @@ export function signal(initial: T, options?: SignalOptions): StateTuple return [get as Accessor, set]; } + +// --------------------------------------------------------------------------- +// external() — reactive integration with state SibuJS does not own. +// +// SibuJS tracks reads of ITS OWN signals. A `Chess` instance, a `` +// scene graph, a CodeMirror document, a WebSocket-owned cache — all keep their +// state in objects the runtime never sees written. Nothing can be tracked, so +// nothing can be invalidated. +// +// The honest primitive for that is a reactive token with NO value: consumers +// declare "I read from this engine" (`track()`), and the code that mutated the +// engine declares "the engine changed" (`invalidate()`). Dependency tracking +// and invalidation are separated, which is precisely the property an external +// engine breaks. +// +// It deliberately does NOT proxy, clone, deep-compare or otherwise observe the +// external object. That is not a limitation to be engineered away later: a +// generic mechanism that could detect arbitrary third-party mutation does not +// exist without owning the data, and pretending otherwise produces silent +// staleness rather than an explicit call site. +// +// WHY IT LIVES IN THIS FILE rather than one of its own: the build splits `dist` +// into shared chunks, and a module reachable only from the root entry lands in +// the large index-only chunk together with `enhance`, `mountIslands`, `mount` +// and `each`. Importing it would then drag the whole island runtime into a page +// that only wanted to make a canvas reactive — 77 KB instead of 12 KB, measured. +// `signal.ts` is in the small chunk every entry point shares, so defining the +// primitive beside the signal it is built from is what keeps it independently +// tree-shakeable. `tests/treeshaking-islands.test.ts` pins that. +// --------------------------------------------------------------------------- + +/** + * A valueless reactive token standing in for state SibuJS does not own. + * + * See {@link external}. + */ +export interface ExternalSource { + /** + * Declare, from inside a reactive computation, that it reads the external + * state this source represents. Call it in the same places you would read a + * signal — the top of a binding getter, a `derived()` body, an `effect()`. + * + * Outside a tracking context it is a no-op, exactly like reading a signal. + */ + track(): void; + /** + * Declare that the external state changed. Every consumer that called + * {@link ExternalSource.track} is invalidated. + * + * Participates in `batch()` like any signal write: inside a batch, consumers + * are notified once when the outermost batch flushes. + */ + invalidate(): void; +} + +/** + * Create a reactive source for state that lives outside SibuJS — a domain + * engine, a media element, a canvas scene, an editor document, a cache a + * socket writes into. + * + * The pattern is two lines: `track()` where you read, `invalidate()` after you + * mutate. + * + * ```ts + * import { Chess } from "chess.js"; + * 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 + * ``` + * + * **One source is one invalidation domain.** Every consumer of a source + * re-runs on every `invalidate()`, so the granularity of your updates is + * exactly the granularity of your sources: one for a whole engine is the + * cheapest to write, several (`board`, `clock`, `history`) let an update touch + * only what it affects. See `docs/architecture/external-state.md` for the + * trade-offs and when subdividing is worth it. + * + * Ownership, disposal and error routing are the consumer's, not the source's: + * a disposed binding or effect is never invalidated, and a consumer that + * throws is reported through the normal runtime error pipeline with its own + * phase and node. + * + * @param options `name` labels the source in devtools (development only). + */ +export function external(options?: { name?: string }): ExternalSource { + // Implemented on top of `signal` rather than against the reactive core + // directly, so batching, the notification drain, version-based + // stabilization, duplicate-runtime coordination and devtools all behave + // identically to every other reactive source with no second implementation + // of those invariants to keep in step. + // + // The counter is an implementation detail and is never handed out: `track()` + // returns void, so no consumer can come to depend on the number, and there + // is no "revision" for application code to thread through its own state. + const [version, bump] = signal(0, options?.name ? { name: options.name } : undefined); + + return { + track(): void { + version(); + }, + invalidate(): void { + bump((n) => n + 1); + }, + }; +} diff --git a/src/platform/enhance.ts b/src/platform/enhance.ts index cb54b14..6d49a04 100644 --- a/src/platform/enhance.ts +++ b/src/platform/enhance.ts @@ -13,14 +13,15 @@ // and ties every binding to disposal — so static content never re-paints. // --------------------------------------------------------------------------- -import { isDev } from "../core/dev"; +import { devAssert, isDev } from "../core/dev"; import { MAX_DRAIN_TEARDOWNS, registerDisposer, reportDrainRunaway, unregisterDisposer, } from "../core/rendering/dispose"; -import { effect } from "../core/signals/effect"; +import { isSSR } from "../core/ssr-context"; +import { reactiveBinding } from "../reactivity/track"; import { setSafeAttribute } from "../utils/setSafeAttribute"; /** Attribute marking a root that *currently* owns an active enhancement. @@ -83,6 +84,41 @@ function drainTeardowns(teardowns: Array<() => void>, label: string): void { } } +/** Event handlers for {@link EachBindings}, typed per event name. */ +export type EachEventBindings = { + [K in keyof HTMLElementEventMap]?: (event: HTMLElementEventMap[K], el: HTMLElement) => void; +}; + +/** + * What {@link EnhanceContext.each} attaches to one element. + * + * Every field maps one-to-one onto an existing `ctx.*` helper and is committed + * through it — this is a shorthand for calls you could write by hand, not a + * template language and not a second binding engine. There is no expression + * parsing, no string interpolation and no `eval`: every value is a plain + * function you wrote, so it stays CSP-safe and fully type-checked. + * + * Anything not covered here (two-way `model()`, listener options, a nested + * `enhance`) is written imperatively in the same callback — it receives the + * element, so `ctx.model(el, …)` beside a returned descriptor is normal. + */ +export interface EachBindings { + /** Reactive `textContent` — same as `ctx.text(el, value)`. */ + text?: () => unknown; + /** Reactive attributes by name — same as `ctx.attr(el, name, value)`. */ + attr?: Record unknown>; + /** Reactive class toggles by class name — same as `ctx.classed(el, name, on)`. */ + class?: Record boolean>; + /** Reactive visibility — same as `ctx.show(el, when)`. */ + show?: () => boolean; + /** Event listeners by event name — same as `ctx.on(el, event, handler)`. */ + on?: EachEventBindings; + /** Per-element teardown, run with the rest of the enhancement's cleanups. */ + cleanup?: () => void; +} + +const EACH_KEYS = ["text", "attr", "class", "show", "on", "cleanup"] as const; + /** * Helpers handed to an `enhance` setup. Every binding is fine-grained (its own * effect) and auto-disposed when the root element (or the returned dispose) is @@ -118,6 +154,47 @@ export interface EnhanceContext { show(target: string | Element | null, when: () => boolean): void; /** Two-way bind a form control to a `[get, set]` signal tuple. */ model(target: string | Element, state: readonly [() => T, (value: T) => void], options?: { event?: string }): void; + /** + * Bind a set of elements the server already rendered — a board, a table, a + * keyboard, a timeline, a legend — one descriptor at a time. + * + * The callback receives each element and its index and returns what to + * attach; every field is committed through the matching `ctx.*` helper, so + * ownership, disposal, write elision, attribute sanitization and error + * routing are byte-for-byte the same as writing the calls out by hand. No + * element is created, replaced, moved or re-parented — node identity is + * preserved, which is the entire point of enhancing existing markup. + * + * ```ts + * ctx.each("@square", (el) => { + * const square = el.dataset.square as Square; + * return { + * text: () => pieceAt(square), + * class: { selected: () => selected() === square }, + * attr: { "aria-label": () => describe(square) }, + * on: { click: () => choose(square) }, + * }; + * }); + * ``` + * + * The callback may also return nothing and wire the element imperatively — + * `ctx.model(el, …)`, `ctx.on(el, "click", h, { passive: true })` — for the + * cases the descriptor deliberately does not cover. + * + * Zero matches is a silent no-op. Calling `each` twice over the same + * elements creates two independent sets of bindings, exactly as calling + * `ctx.text()` twice on one node does; the helper is sugar over those calls + * and does not track what a previous call attached. + * + * @param target A `@ref`/CSS selector resolved with {@link EnhanceContext.refs}, + * or any iterable of elements (an array, a `NodeList`, an `HTMLCollection`). + * @param describe Called once per element, in document order. + */ + each( + target: string | Iterable, + // biome-ignore lint/suspicious/noConfusingVoidType: intentional "a descriptor, or nothing" return — the callback may wire the element imperatively instead. Mirrors EnhanceSetup. + describe: (element: T, index: number) => EachBindings | void, + ): void; /** Register arbitrary teardown to run on disposal. */ cleanup(fn: () => void): void; } @@ -141,6 +218,30 @@ function resolveTarget(root: HTMLElement, target: string | Element | null): HTML } } +/** + * Create one fine-grained binding for a node. + * + * Every reactive helper on the {@link EnhanceContext} goes through here, so + * enhancement bindings are indistinguishable from the runtime's other DOM + * bindings (`bindTextNode`, `bindAttribute`, the tag factory's class/style + * writers): the subscriber is stamped `_errorPhase: "binding"` and carries the + * node it owns. + * + * That metadata is only ever read on the failure path, and it is the whole + * reason this is not a plain `effect()`. A binding that throws on a LATER run + * is reported by the notification drain, which has no other way to know it was + * looking at a DOM binding or which node it belonged to — and `reportError()` + * offers a node's enclosing `ErrorBoundary` first refusal, so an enhancement + * binding with no node could never reach a boundary at all. + * + * SSR parity with `effect()` is deliberate: side effects do not run on the + * server, so a binding created during SSR is inert and its disposer is a no-op. + */ +function bindNode(el: HTMLElement, commit: () => void): () => void { + if (isSSR()) return () => {}; + return reactiveBinding(commit, el); +} + function readControlValue(el: HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement): unknown { if (el instanceof HTMLInputElement) { if (el.type === "checkbox") return el.checked; @@ -166,6 +267,69 @@ function writeControlValue(el: HTMLInputElement | HTMLSelectElement | HTMLTextAr if (el.value !== next) el.value = next; } +/** + * Commit one {@link EachBindings} descriptor through the ordinary context + * helpers. + * + * Routing everything back through `ctx` is what keeps `each` sugar rather than + * a second implementation: there is no binding path here that `ctx.text` / + * `ctx.attr` / `ctx.classed` / `ctx.show` / `ctx.on` do not already own, so + * error metadata, sanitization, write elision and teardown registration cannot + * drift between the two ways of writing the same thing. + * + * Shape mistakes are caught in development with the element's index and the + * offending key, because a descriptor is data and a typo in it would otherwise + * fail silently (an unknown key) or as an opaque `x is not a function` on a + * later drain (a value that is not a getter). The assertion throws inside + * `setup`, so `enhance`'s transaction rolls the whole enhancement back — the + * element is left exactly as the server sent it. + */ +function applyEachBindings(ctx: EnhanceContext, el: HTMLElement, spec: EachBindings, index: number): void { + const where = `ctx.each[${index}]`; + if (isDev()) { + for (const key of Object.keys(spec)) { + devAssert( + (EACH_KEYS as readonly string[]).includes(key), + `${where}: unknown binding "${key}". Expected one of: ${EACH_KEYS.join(", ")}.`, + ); + } + } + + if (spec.text !== undefined) { + devAssert(typeof spec.text === "function", `${where}: "text" must be a function returning the value.`); + ctx.text(el, spec.text); + } + if (spec.attr !== undefined) { + for (const name of Object.keys(spec.attr)) { + const value = spec.attr[name]; + devAssert(typeof value === "function", `${where}: attr["${name}"] must be a function returning the value.`); + ctx.attr(el, name, value); + } + } + if (spec.class !== undefined) { + for (const name of Object.keys(spec.class)) { + const on = spec.class[name]; + devAssert(typeof on === "function", `${where}: class["${name}"] must be a function returning a boolean.`); + ctx.classed(el, name, on); + } + } + if (spec.show !== undefined) { + devAssert(typeof spec.show === "function", `${where}: "show" must be a function returning a boolean.`); + ctx.show(el, spec.show); + } + if (spec.on !== undefined) { + for (const event of Object.keys(spec.on) as Array) { + const handler = spec.on[event]; + devAssert(typeof handler === "function", `${where}: on["${String(event)}"] must be a function.`); + ctx.on(el, event, handler as (e: HTMLElementEventMap[typeof event], el: HTMLElement) => void); + } + } + if (spec.cleanup !== undefined) { + devAssert(typeof spec.cleanup === "function", `${where}: "cleanup" must be a function.`); + ctx.cleanup(spec.cleanup); + } +} + /** * Attach reactivity to an existing element (typically server-rendered) without * replacing it. Returns a dispose function; disposal is also wired to the @@ -266,7 +430,7 @@ export function enhance(target: Element | string, setup: EnhanceSetup): () => vo text: (t, value) => { bind(t, (el) => { teardowns.push( - effect(() => { + bindNode(el, () => { const v = value(); const next = v == null ? "" : String(v); // Skip no-op writes: when the value already matches the server @@ -280,7 +444,7 @@ export function enhance(target: Element | string, setup: EnhanceSetup): () => vo attr: (t, name, value) => { bind(t, (el) => { teardowns.push( - effect(() => { + bindNode(el, () => { const v = value(); // null/undefined removes the attribute; everything else (including // booleans) is serialized literally — so `aria-expanded` reads @@ -314,7 +478,7 @@ export function enhance(target: Element | string, setup: EnhanceSetup): () => vo classed: (t, name, on) => { bind(t, (el) => { teardowns.push( - effect(() => { + bindNode(el, () => { el.classList.toggle(name, Boolean(on())); }), ); @@ -328,7 +492,7 @@ export function enhance(target: Element | string, setup: EnhanceSetup): () => vo // `style.display` alone could not override a server `hidden` attribute. const prevHidden = el.hidden; teardowns.push( - effect(() => { + bindNode(el, () => { el.hidden = !when(); }), ); @@ -349,7 +513,7 @@ export function enhance(target: Element | string, setup: EnhanceSetup): () => vo : "input"); // Signal → control (writeControlValue skips no-op writes internally). teardowns.push( - effect(() => { + bindNode(el, () => { writeControlValue(control, get()); }), ); @@ -359,6 +523,19 @@ export function enhance(target: Element | string, setup: EnhanceSetup): () => vo teardowns.push(() => control.removeEventListener(evt, onInput)); }); }, + each: (target_, describe) => { + devAssert(typeof describe === "function", "ctx.each: second argument must be a function."); + const elements = + typeof target_ === "string" ? ctx.refs(target_) : (Array.from(target_) as HTMLElement[]); + // Zero matches is a legitimate state (an empty list, a table with no + // rows), so it is a silent no-op rather than a warning. + for (let i = 0; i < elements.length; i++) { + const el = elements[i]; + const spec = describe(el as never, i); + if (spec == null) continue; + applyEachBindings(ctx, el, spec, i); + } + }, cleanup: (fn) => { teardowns.push(fn); }, diff --git a/src/reactivity/track-core.ts b/src/reactivity/track-core.ts index 74298a4..33e7ebf 100644 --- a/src/reactivity/track-core.ts +++ b/src/reactivity/track-core.ts @@ -566,7 +566,32 @@ export function reactiveBinding(commit: () => void, ownerNode?: unknown): () => subscriber._errorNode = ownerNode; // Initial run establishes the first dependency set (guarded, see above). - run(); + // + // It is wrapped because a commit that reads a signal and THEN throws would + // otherwise leave a subscriber nobody can reach: `retrack` has already linked + // the edge, but the throw escapes before the disposer below exists, so no + // caller ever receives one. The binding is then a zombie — the next write to + // that signal re-runs the commit and mutates DOM belonging to a construction + // that failed. For `enhance()` that silently broke the documented transaction + // ("a failed setup claims nothing"): its rollback drains a teardown list the + // binding was never added to. + // + // Unwinding here is what makes "the binding was never created" true rather + // than merely intended. The subscriber is marked disposed first, so anything + // already queued for it in the current drain is skipped by the guard above; + // the owner node is dropped so a failed binding cannot retain a DOM subtree; + // and the edges go through the same `cleanup()` every disposal uses, so there + // is exactly one place that knows how to unlink a subscriber. The original + // error is rethrown untouched — the caller's `catch` must see what its own + // code threw, not a framework wrapper. + try { + run(); + } catch (err) { + subscriber._disposed = true; + subscriber._errorNode = undefined; + cleanup(subscriber); + throw err; + } return ( subscriber._dispose ?? diff --git a/tests-browser/chess.spec.ts b/tests-browser/chess.spec.ts new file mode 100644 index 0000000..842e2fd --- /dev/null +++ b/tests-browser/chess.spec.ts @@ -0,0 +1,261 @@ +import { expect, test } from "@playwright/test"; + +// --------------------------------------------------------------------------- +// Real-browser coverage for the chess reference island. +// +// These are the behaviours jsdom cannot validate honestly: real focus movement, +// `:focus-visible`, native button activation from the keyboard, live-region +// semantics, and whether a reactive update disturbs the focused element. +// +// Runs on Chromium, Firefox and WebKit (see playwright.config.ts). +// --------------------------------------------------------------------------- + +const PAGE = "/examples/chess/"; + +const board = (n: 1 | 2) => `[data-board="${n}"]`; +const square = (n: 1 | 2, sq: string) => `${board(n)} [data-square="${sq}"]`; + +/** The shortest legal line that ends in a promotion (verified against chess.js). */ +const TO_PROMOTION: Array<[string, string]> = [ + ["h2", "h4"], + ["g7", "g5"], + ["h4", "g5"], + ["h7", "h6"], + ["g5", "h6"], + ["a7", "a6"], + ["h6", "h7"], + ["b7", "b6"], +]; + +test.beforeEach(async ({ page }) => { + await page.goto(PAGE); + await expect(page.locator(`${board(1)}[data-sibu-enhanced="true"]`)).toHaveCount(1); +}); + +/** + * Play the fixture moves that get a test to an interesting position. + * + * These dispatch real `click` events through the real listeners — the only + * thing skipped is pointer emulation, which is not what these tests are about + * and costs a hit-test per square. The behaviour under test always uses real + * `page.click` / `page.keyboard`. + */ +async function playFixture(page: import("@playwright/test").Page, moves: Array<[string, string]>) { + await page.evaluate((list) => { + const b = document.querySelector('[data-board="1"]') as HTMLElement; + for (const [from, to] of list) { + b.querySelector(`[data-square="${from}"]`)?.click(); + b.querySelector(`[data-square="${to}"]`)?.click(); + } + }, moves); +} + +test("a mouse move updates only the squares involved, and keeps every node", async ({ page }) => { + const ids = await page.$$eval(`${board(1)} [data-square]`, (els) => els.map((el, i) => ((el as HTMLElement).dataset.probe = String(i)))); + expect(ids).toHaveLength(64); + + await page.click(square(1, "e2")); + await expect(page.locator(square(1, "e2"))).toHaveAttribute("data-marks", "selected"); + await expect(page.locator(square(1, "e4"))).toHaveAttribute("data-marks", "legal"); + + await page.click(square(1, "e4")); + await expect(page.locator(`${square(1, "e4")} .piece`)).toHaveText("♙"); + await expect(page.locator(`${square(1, "e2")} .piece`)).toHaveText(""); + await expect(page.locator(`${board(1)} [data-ref="status"]`)).toContainText("Black to move"); + + // Every square is the SAME element it was before the move — the marker + // written into the DOM above survived, so nothing was rebuilt. + const probes = await page.$$eval(`${board(1)} [data-square]`, (els) => + els.map((el) => (el as HTMLElement).dataset.probe), + ); + expect(probes).toEqual(Array.from({ length: 64 }, (_, i) => String(i))); +}); + +test("keyboard: arrows navigate the grid and Enter/Space play the move", async ({ page }) => { + await page.locator(square(1, "e2")).focus(); + await expect(page.locator(square(1, "e2"))).toBeFocused(); + + await page.keyboard.press("ArrowUp"); + await expect(page.locator(square(1, "e3"))).toBeFocused(); + await page.keyboard.press("ArrowLeft"); + await expect(page.locator(square(1, "d3"))).toBeFocused(); + await page.keyboard.press("Home"); + await expect(page.locator(square(1, "a3"))).toBeFocused(); + await page.keyboard.press("End"); + await expect(page.locator(square(1, "h3"))).toBeFocused(); + + // Enter on a real +
`; + + const [n, setN] = signal(0); + enhance("[data-counter]", (ctx) => { + ctx.text("@n", () => n()); + ctx.on("@inc", "click", () => setN((v) => v + 1)); + }); + + (document.querySelector('[data-ref="inc"]') as HTMLButtonElement).click(); + expect(document.querySelector('[data-ref="n"]')?.textContent).toBe("1"); + }); + + it("the zero-build island registration", async () => { + document.body.innerHTML = ` +
+ 0 + +
`; + + registerIsland("counter", (ctx) => { + const [n, setN] = signal(0); + ctx.text("@n", () => n()); + ctx.on("@inc", "click", () => setN((v) => v + 1)); + }); + const stop = mountIslands(document); + await flush(); + + (document.querySelector('[data-ref="inc"]') as HTMLButtonElement).click(); + expect(document.querySelector('[data-ref="n"]')?.textContent).toBe("1"); + stop(); + }); + + it("the `external()` engine snippet", () => { + // Stands in for the documented Chess instance: an object SibuJS cannot see + // written, with the same shape of read used in the doc. + const game = { turn: () => turn, isCheckmate: () => false }; + let turn = "w"; + + document.body.innerHTML = `
`; + const moved = external(); + + enhance("#w", (ctx) => { + ctx.text("@status", () => { + moved.track(); + return game.isCheckmate() ? "Checkmate" : `${game.turn()} to move`; + }); + }); + + expect(document.querySelector('[data-ref="status"]')?.textContent).toBe("w to move"); + turn = "b"; + moved.invalidate(); + expect(document.querySelector('[data-ref="status"]')?.textContent).toBe("b to move"); + }); + + it("the `ctx.each` descriptor, with the documented field set", () => { + document.body.innerHTML = ` +
+ + +
`; + + const [selected, setSelected] = signal(null); + const clicks: string[] = []; + + enhance("#w", (ctx) => { + ctx.each("@square", (el) => { + const square = el.dataset.square as string; + return { + text: () => (selected() === square ? "x" : ""), + class: { selected: () => selected() === square }, + attr: { "aria-selected": () => selected() === square }, + show: () => true, + on: { click: () => clicks.push(square) }, + cleanup: () => clicks.push(`bye:${square}`), + }; + }); + }); + + setSelected("a2"); + const a2 = document.querySelector('[data-square="a2"]') as HTMLButtonElement; + expect(a2.classList.contains("selected")).toBe(true); + expect(a2.getAttribute("aria-selected")).toBe("true"); + a2.click(); + expect(clicks).toEqual(["a2"]); + }); + + it("the enhance + mount composition, including the `when()` wrapper gotcha", async () => { + document.body.innerHTML = ` +
+ idle + +
+
`; + + const rows: Array<{ n: number; san: string }> = []; + const changed = external(); + + registerIsland("chess", (ctx) => { + ctx.text("@status", () => { + changed.track(); + return rows.length === 0 ? "idle" : `${rows.length} moves`; + }); + ctx.each("@square", (el) => ({ + on: { + click: () => { + rows.push({ n: rows.length + 1, san: el.dataset.square as string }); + changed.invalidate(); + }, + }, + })); + + const slot = ctx.ref("@history") as HTMLElement; + slot.textContent = ""; + const history = mount( + () => + // Wrapped, exactly as the doc says: `when` inserts its branch as a + // SIBLING of its anchor, so the mount must own an element containing + // both or unmounting leaves the branch behind. + div( + when( + () => { + changed.track(); + return rows.length > 0; + }, + () => + ol( + { "data-ref": "moves" }, + each( + () => { + changed.track(); + return rows.slice(); + }, + (row) => li(() => row().san), + { key: (row) => row.n }, + ), + ), + () => p({ "data-ref": "empty" }, "No moves yet."), + ), + ), + slot, + ); + ctx.cleanup(history.unmount); + }); + + const stop = mountIslands(document); + await flush(); + + const root = document.querySelector("[data-sibu-island]") as HTMLElement; + expect(root.querySelector('[data-ref="empty"]')).not.toBe(null); + + (root.querySelector('[data-square="a1"]') as HTMLButtonElement).click(); + await flush(); + expect(root.querySelector('[data-ref="status"]')?.textContent).toBe("1 moves"); + expect(Array.from(root.querySelectorAll('[data-ref="moves"] li'), (n) => n.textContent)).toEqual(["a1"]); + + stop(); + // The wrapper is what makes this assertion pass: anchor AND branch are gone, + // while every server-rendered node around them is untouched. + expect(root.querySelector('[data-ref="history"]')?.innerHTML).toBe(""); + expect(root.querySelectorAll("[data-square]")).toHaveLength(1); + }); +}); + +describe("the islands docs name only things that exist", () => { + const files = ["islands.md", "interop.md", "architecture/external-state.md"]; + + /** Fenced code blocks, so prose mentioning a name is not mistaken for code. */ + function codeBlocks(source: string): string[] { + return [...source.matchAll(/```[a-z]*\n([\s\S]*?)```/g)].map((m) => m[1]); + } + + it("every documented `ctx.*` helper is a real EnhanceContext member", () => { + // The runtime shape, taken from a real context rather than a hand-written list. + document.body.innerHTML = `
`; + let members: string[] = []; + enhance("#probe", (ctx) => { + members = Object.keys(ctx); + })(); + + const used = new Set(); + for (const file of files) { + for (const block of codeBlocks(read(file))) { + for (const m of block.matchAll(/\bctx\.([a-zA-Z]+)/g)) used.add(m[1]); + } + } + + expect(used.size).toBeGreaterThan(5); + expect([...used].filter((name) => !members.includes(name))).toEqual([]); + }); + + it("every value imported from `sibujs` in the docs is exported by it", async () => { + const index = await import("../index"); + const ui = await import("../ui"); + const patterns = await import("../patterns"); + const barrels: Record> = { + sibujs: index as unknown as Record, + "sibujs/ui": ui as unknown as Record, + "sibujs/patterns": patterns as unknown as Record, + }; + + const missing: string[] = []; + for (const file of files) { + for (const block of codeBlocks(read(file))) { + for (const m of block.matchAll(/import\s*\{([^}]+)\}\s*from\s*"(sibujs(?:\/[a-z]+)?)"/g)) { + const barrel = barrels[m[2]]; + if (!barrel) continue; // a subpath this test does not load + for (const raw of m[1].split(",")) { + const name = raw + .trim() + .replace(/^type\s+/, "") + .split(/\s+as\s+/)[0]; + if (!name) continue; + if (!(name in barrel)) missing.push(`${file}: ${name} is not exported by ${m[2]}`); + } + } + } + } + + expect(missing).toEqual([]); + }); + + it("the guides link only to files that exist", () => { + const root = resolve(__dirname, ".."); + const broken: string[] = []; + for (const file of files) { + const dir = resolve(DOCS, file, ".."); + for (const m of read(file).matchAll(/\]\((\.\.?\/[^)#\s]+)/g)) { + const target = resolve(dir, m[1]); + try { + readFileSync(target); + } catch { + // A directory link (e.g. `examples/chess/`) is fine if it resolves. + try { + readFileSync(resolve(target, "README.md")); + } catch { + broken.push(`${file} → ${m[1]}`); + } + } + } + expect(root.length).toBeGreaterThan(0); + } + expect(broken).toEqual([]); + }); +}); diff --git a/tests/enhance-binding-error-metadata.test.ts b/tests/enhance-binding-error-metadata.test.ts new file mode 100644 index 0000000..c4e22b3 --- /dev/null +++ b/tests/enhance-binding-error-metadata.test.ts @@ -0,0 +1,165 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { type RuntimeErrorContext, setRuntimeErrorHandler } from "../src/core/errors"; +import { signal } from "../src/core/signals/signal"; +import { enhance } from "../src/platform/enhance"; + +// --------------------------------------------------------------------------- +// Enhancement bindings must carry the SAME error metadata as every other DOM +// binding in the runtime (`bindTextNode`, `bindAttribute`, tagFactory's +// class/style bindings): phase `"binding"` and the owning node. +// +// They were created with `effect()`, whose subscribers are stamped +// `_errorPhase: "effect"` and deliberately carry NO `_errorNode` (a generic +// effect has no DOM position). So a binding that failed on a LATER run — the +// only path where the drain, not the caller, reports the failure — was +// reported as an effect with no node. `reportError` gives a node's enclosing +// `ErrorBoundary` first refusal, so with no node that branch was unreachable +// for every progressive-enhancement binding on the page. +// --------------------------------------------------------------------------- + +function serverRender(html: string): HTMLElement { + const host = document.createElement("div"); + host.innerHTML = html.trim(); + document.body.appendChild(host); + return host.firstElementChild as HTMLElement; +} + +let reports: Array<{ error: unknown; context: RuntimeErrorContext }>; + +beforeEach(() => { + reports = []; + setRuntimeErrorHandler((error, context) => { + reports.push({ error, context }); + }); +}); + +afterEach(() => { + setRuntimeErrorHandler(null); + document.body.innerHTML = ""; + vi.restoreAllMocks(); +}); + +describe("enhance() bindings report as DOM bindings, not as generic effects", () => { + it("text() stamps phase 'binding' and the owning node", () => { + const root = serverRender(`
0
`); + const node = root.querySelector('[data-ref="n"]') as HTMLElement; + const [n, setN] = signal(0); + + enhance(root, (ctx) => { + ctx.text("@n", () => { + const value = n(); + if (value > 0) throw new Error("text boom"); + return value; + }); + }); + + setN(1); + + expect(reports).toHaveLength(1); + expect((reports[0].error as Error).message).toBe("text boom"); + expect(reports[0].context.phase).toBe("binding"); + expect(reports[0].context.node).toBe(node); + }); + + it("attr() stamps phase 'binding' and the owning node", () => { + const root = serverRender(``); + const node = root.querySelector('[data-ref="link"]') as HTMLElement; + const [on, setOn] = signal(false); + + enhance(root, (ctx) => { + ctx.attr("@link", "aria-expanded", () => { + if (on()) throw new Error("attr boom"); + return false; + }); + }); + + setOn(true); + + expect(reports).toHaveLength(1); + expect(reports[0].context.phase).toBe("binding"); + expect(reports[0].context.node).toBe(node); + }); + + it("classed() stamps phase 'binding' and the owning node", () => { + const root = serverRender(`
x
`); + const node = root.querySelector('[data-ref="s"]') as HTMLElement; + const [on, setOn] = signal(false); + + enhance(root, (ctx) => { + ctx.classed("@s", "active", () => { + if (on()) throw new Error("class boom"); + return false; + }); + }); + + setOn(true); + + expect(reports).toHaveLength(1); + expect(reports[0].context.phase).toBe("binding"); + expect(reports[0].context.node).toBe(node); + }); + + it("show() stamps phase 'binding' and the owning node", () => { + const root = serverRender(`

x

`); + const node = root.querySelector('[data-ref="p"]') as HTMLElement; + const [on, setOn] = signal(true); + + enhance(root, (ctx) => { + ctx.show("@p", () => { + if (!on()) throw new Error("show boom"); + return true; + }); + }); + + setOn(false); + + expect(reports).toHaveLength(1); + expect(reports[0].context.phase).toBe("binding"); + expect(reports[0].context.node).toBe(node); + }); + + it("model() stamps phase 'binding' and the owning node", () => { + const root = serverRender(`
`); + const node = root.querySelector('[data-ref="i"]') as HTMLElement; + const [value, setValue] = signal("a"); + + enhance(root, (ctx) => { + ctx.model("@i", [ + () => { + const v = value(); + if (v === "boom") throw new Error("model boom"); + return v; + }, + setValue, + ]); + }); + + setValue("boom"); + + expect(reports).toHaveLength(1); + expect(reports[0].context.phase).toBe("binding"); + expect(reports[0].context.node).toBe(node); + }); + + it("a disposed binding is never invalidated again", () => { + const root = serverRender(`
0
`); + const [n, setN] = signal(0); + let runs = 0; + + const stop = enhance(root, (ctx) => { + ctx.text("@n", () => { + runs++; + return n(); + }); + }); + + expect(runs).toBe(1); + setN(1); + expect(runs).toBe(2); + + stop(); + setN(2); + expect(runs).toBe(2); + expect(reports).toEqual([]); + }); +}); diff --git a/tests/enhance-each.test.ts b/tests/enhance-each.test.ts new file mode 100644 index 0000000..c045efb --- /dev/null +++ b/tests/enhance-each.test.ts @@ -0,0 +1,391 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { type RuntimeErrorContext, setRuntimeErrorHandler } from "../src/core/errors"; +import { external, signal } from "../src/core/signals/signal"; +import { enhance } from "../src/platform/enhance"; + +// --------------------------------------------------------------------------- +// ctx.each() — repeated enhancement over elements the server already rendered. +// +// The fixture is a real 64-square board, because that is the shape the helper +// exists for: a dense grid of durable nodes, each needing several independent +// bindings, where a component framework would rebuild all 64 to change one. +// --------------------------------------------------------------------------- + +const FILES = ["a", "b", "c", "d", "e", "f", "g", "h"] as const; +const RANKS = ["8", "7", "6", "5", "4", "3", "2", "1"] as const; + +/** Every square name in render order, e.g. "a8" … "h1". */ +const SQUARES: string[] = RANKS.flatMap((rank) => FILES.map((file) => `${file}${rank}`)); + +function board(): HTMLElement { + const host = document.createElement("div"); + host.innerHTML = `
${SQUARES.map( + (sq) => ``, + ).join("")}
`; + document.body.appendChild(host); + return host.firstElementChild as HTMLElement; +} + +const squareEls = (root: HTMLElement) => Array.from(root.querySelectorAll("[data-square]")); + +afterEach(() => { + setRuntimeErrorHandler(null); + document.body.innerHTML = ""; + vi.restoreAllMocks(); +}); + +describe("ctx.each() — binding a 64-element board", () => { + it("binds text, classes, attributes and events on every square", () => { + const root = board(); + const before = squareEls(root); + const [selected, setSelected] = signal(null); + const clicks: string[] = []; + + enhance(root, (ctx) => { + ctx.each("@square", (el) => { + const square = el.dataset.square as string; + return { + class: { selected: () => selected() === square }, + attr: { "aria-selected": () => selected() === square }, + on: { click: () => clicks.push(square) }, + }; + }); + ctx.each("@piece", (_el, index) => ({ + text: () => (selected() === SQUARES[index] ? "♞" : ""), + })); + }); + + expect(before.every((el) => el.getAttribute("aria-selected") === "false")).toBe(true); + + setSelected("e4"); + const e4 = root.querySelector('[data-square="e4"]') as HTMLButtonElement; + expect(e4.classList.contains("selected")).toBe(true); + expect(e4.getAttribute("aria-selected")).toBe("true"); + expect((e4.firstElementChild as HTMLElement).textContent).toBe("♞"); + + // Exactly one square is selected — the other 63 were untouched. + expect(root.querySelectorAll(".selected")).toHaveLength(1); + expect(root.querySelectorAll('[aria-selected="true"]')).toHaveLength(1); + + e4.click(); + expect(clicks).toEqual(["e4"]); + }); + + it("preserves node identity — nothing is created, replaced or moved", () => { + const root = board(); + const before = squareEls(root); + const [n, setN] = signal(0); + + enhance(root, (ctx) => { + ctx.each("@square", (_el, i) => ({ + text: () => `${i + n()}`, + })); + }); + + setN(1); + setN(2); + + const after = squareEls(root); + expect(after).toHaveLength(64); + for (let i = 0; i < 64; i++) expect(after[i]).toBe(before[i]); + }); + + it("passes the element and its index, in document order", () => { + const root = board(); + const seen: [string, number][] = []; + + enhance(root, (ctx) => { + ctx.each("@square", (el, index) => { + seen.push([el.dataset.square as string, index]); + }); + }); + + expect(seen).toHaveLength(64); + expect(seen[0]).toEqual(["a8", 0]); + expect(seen[63]).toEqual(["h1", 63]); + }); + + it("supports show() per element", () => { + const root = board(); + const [dark, setDark] = signal(true); + + enhance(root, (ctx) => { + ctx.each("@square", (_el, index) => ({ + show: () => (index % 2 === 0 ? true : dark()), + })); + }); + + expect(squareEls(root).filter((el) => el.hidden)).toHaveLength(0); + setDark(false); + expect(squareEls(root).filter((el) => el.hidden)).toHaveLength(32); + }); + + it("accepts an element collection as well as a selector", () => { + const root = board(); + const [n, setN] = signal(0); + + enhance(root, (ctx) => { + ctx.each(root.querySelectorAll("[data-square]"), (_el) => ({ text: () => `${n()}` })); + }); + + setN(7); + expect(squareEls(root).every((el) => el.textContent === "7")).toBe(true); + }); + + it("accepts the array returned by ctx.refs()", () => { + const root = board(); + const [n, setN] = signal(0); + + enhance(root, (ctx) => { + ctx.each(ctx.refs("@square").slice(0, 3), () => ({ text: () => `${n()}` })); + }); + + setN(4); + const texts = squareEls(root).map((el) => el.textContent); + expect(texts.slice(0, 3)).toEqual(["4", "4", "4"]); + expect(texts.slice(3).every((t) => t === "")).toBe(true); + }); + + it("mixes descriptors with imperative ctx calls in the same callback", () => { + const root = board(); + const [value, setValue] = signal("x"); + const keydowns: string[] = []; + + enhance(root, (ctx) => { + ctx.each("@square", (el, index) => { + if (index === 0) { + // Listener options are deliberately not in the descriptor — reach for + // the ordinary helper with the element in hand. + ctx.on(el, "keydown", (event) => keydowns.push(event.key), { capture: true }); + } + return { text: () => value() }; + }); + }); + + setValue("y"); + expect(squareEls(root)[0].textContent).toBe("y"); + squareEls(root)[0].dispatchEvent(new KeyboardEvent("keydown", { key: "Enter" })); + expect(keydowns).toEqual(["Enter"]); + }); +}); + +describe("ctx.each() — lifecycle", () => { + it("disposes every generated binding, listener and per-element cleanup with the island", () => { + const root = board(); + const [n, setN] = signal(0); + const clicks: string[] = []; + const cleaned: string[] = []; + + const stop = enhance(root, (ctx) => { + ctx.each("@square", (el) => { + const square = el.dataset.square as string; + return { + text: () => `${n()}`, + on: { click: () => clicks.push(square) }, + cleanup: () => cleaned.push(square), + }; + }); + }); + + setN(1); + expect(squareEls(root)[0].textContent).toBe("1"); + + stop(); + + expect(cleaned).toHaveLength(64); + setN(2); + expect(squareEls(root)[0].textContent).toBe("1"); // bindings stopped + squareEls(root)[0].click(); + expect(clicks).toEqual([]); // listeners removed + }); + + it("rolls back everything already attached when the callback throws mid-board", () => { + const root = board(); + const [n, setN] = signal(0); + const clicks: string[] = []; + + expect(() => + enhance(root, (ctx) => { + ctx.each("@square", (_el, index) => { + if (index === 10) throw new Error("bad square"); + return { text: () => `${n()}`, on: { click: () => clicks.push("x") } }; + }); + }), + ).toThrow("bad square"); + + // Transaction: the root never claimed ownership, so it is enhanceable again. + expect(root.getAttribute("data-sibu-enhanced")).toBe(null); + + // Every binding and listener the first ten squares received is gone. Values + // those bindings already wrote are NOT rolled back — `enhance` reverses the + // subscriptions it owns, not DOM writes (same contract as a bare + // `ctx.text()` before a throw); what matters is that nothing stays live. + const bound = squareEls(root).slice(0, 10); + expect(bound.map((el) => el.textContent)).toEqual(Array(10).fill("0")); + setN(5); + expect(bound.map((el) => el.textContent)).toEqual(Array(10).fill("0")); + expect( + squareEls(root) + .slice(10) + .every((el) => el.textContent === ""), + ).toBe(true); + squareEls(root)[0].click(); + expect(clicks).toEqual([]); + }); + + it("calling each() twice creates independent bindings (documented, not deduplicated)", () => { + const root = board(); + const [a, setA] = signal("a"); + const [b, setB] = signal("b"); + + enhance(root, (ctx) => { + ctx.each("@square", () => ({ class: { one: () => a() === "on" } })); + ctx.each("@square", () => ({ class: { two: () => b() === "on" } })); + }); + + setA("on"); + setB("on"); + const first = squareEls(root)[0]; + expect(first.classList.contains("one")).toBe(true); + expect(first.classList.contains("two")).toBe(true); + }); + + it("zero matches is a silent no-op", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const root = board(); + let called = 0; + + expect(() => + enhance(root, (ctx) => { + ctx.each("@nothing-here", () => { + called++; + }); + ctx.each([], () => { + called++; + }); + }), + ).not.toThrow(); + + expect(called).toBe(0); + expect(warn).not.toHaveBeenCalled(); + }); +}); + +describe("ctx.each() — error metadata parity with hand-written bindings", () => { + it("a failing generated binding reports phase 'binding' and its own element", () => { + const reports: Array<{ error: unknown; context: RuntimeErrorContext }> = []; + setRuntimeErrorHandler((error, context) => reports.push({ error, context })); + + const root = board(); + const [armed, setArmed] = signal(false); + + enhance(root, (ctx) => { + ctx.each("@square", (_el, index) => ({ + text: () => { + if (armed() && index === 42) throw new Error("square 42 boom"); + return ""; + }, + })); + }); + + setArmed(true); + + expect(reports).toHaveLength(1); + expect((reports[0].error as Error).message).toBe("square 42 boom"); + expect(reports[0].context.phase).toBe("binding"); + expect(reports[0].context.node).toBe(squareEls(root)[42]); + }); + + it("one failing square does not stop the other 63 from updating", () => { + setRuntimeErrorHandler(() => {}); + const root = board(); + const [n, setN] = signal(0); + + enhance(root, (ctx) => { + ctx.each("@square", (_el, index) => ({ + text: () => { + const v = n(); + if (v > 0 && index === 5) throw new Error("boom"); + return `${v}`; + }, + })); + }); + + setN(1); + const texts = squareEls(root).map((el) => el.textContent); + expect(texts.filter((t) => t === "1")).toHaveLength(63); + expect(texts[5]).toBe("0"); // the throwing square kept its last good value + }); +}); + +describe("ctx.each() — development diagnostics", () => { + it("rejects an unknown binding key with the element index", () => { + const root = board(); + expect(() => + enhance(root, (ctx) => { + ctx.each("@square", () => ({ txt: () => "typo" }) as never); + }), + ).toThrow(/ctx\.each\[0\].*unknown binding "txt"/s); + }); + + it("rejects a non-function value where a getter is required", () => { + const root = board(); + expect(() => + enhance(root, (ctx) => { + ctx.each("@square", () => ({ text: "not a getter" }) as never); + }), + ).toThrow(/ctx\.each\[0\]: "text" must be a function/); + + expect(() => + enhance(root, (ctx) => { + ctx.each("@square", () => ({ class: { on: true } }) as never); + }), + ).toThrow(/ctx\.each\[0\]: class\["on"\] must be a function/); + + expect(() => + enhance(root, (ctx) => { + ctx.each("@square", () => ({ on: { click: 1 } }) as never); + }), + ).toThrow(/ctx\.each\[0\]: on\["click"\] must be a function/); + }); + + it("rejects a non-function describe callback", () => { + const root = board(); + expect(() => + enhance(root, (ctx) => { + (ctx.each as unknown as (t: string, d: unknown) => void)("@square", null); + }), + ).toThrow(/ctx\.each: second argument must be a function/); + }); +}); + +describe("ctx.each() — with an external engine", () => { + it("one invalidation refreshes every square that reads the engine", () => { + const root = board(); + // A deliberately opaque engine: SibuJS cannot see writes into it. + const pieces = new Map([["e2", "♙"]]); + const moved = external(); + + enhance(root, (ctx) => { + ctx.each("@piece", (_el, index) => ({ + text: () => { + moved.track(); + return pieces.get(SQUARES[index]) ?? ""; + }, + })); + }); + + const pieceAt = (sq: string) => + (root.querySelector(`[data-square="${sq}"] [data-ref="piece"]`) as HTMLElement).textContent; + + expect(pieceAt("e2")).toBe("♙"); + expect(pieceAt("e4")).toBe(""); + + pieces.delete("e2"); + pieces.set("e4", "♙"); + moved.invalidate(); + + expect(pieceAt("e2")).toBe(""); + expect(pieceAt("e4")).toBe("♙"); + }); +}); diff --git a/tests/example-chess-smoke.test.ts b/tests/example-chess-smoke.test.ts new file mode 100644 index 0000000..d19bc7d --- /dev/null +++ b/tests/example-chess-smoke.test.ts @@ -0,0 +1,142 @@ +import { type ChildProcess, spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +// --------------------------------------------------------------------------- +// Deployment smoke test for the chess reference example. +// +// A reference application that does not LOAD is worse than none: the failure +// shows up as "site not found" or a blank page long after the commit that +// caused it. So this does not inspect files on disk — it serves the example the +// way it is actually served and walks the module graph the browser would walk, +// asserting every URL answers 200 with content. +// +// Requires `npm run build` (for `dist/`) and `npm run example:chess:build` +// (for the vendored engine) — the same precondition as consumption.test.ts. +// --------------------------------------------------------------------------- + +const ROOT = resolve(__dirname, ".."); +const PORT = 5177; +const BASE = `http://127.0.0.1:${PORT}`; + +const distBuilt = existsSync(resolve(ROOT, "dist/index.js")); +const vendorBuilt = existsSync(resolve(ROOT, "examples/chess/vendor/chess.js")); + +let server: ChildProcess | undefined; + +async function waitForServer(timeoutMs = 15_000): Promise { + const deadline = Date.now() + timeoutMs; + let lastError: unknown; + while (Date.now() < deadline) { + try { + const res = await fetch(`${BASE}/examples/chess/`); + if (res.ok) return; + lastError = new Error(`status ${res.status}`); + } catch (err) { + lastError = err; + } + await new Promise((r) => setTimeout(r, 50)); + } + throw new Error(`example server never became ready: ${String(lastError)}`); +} + +beforeAll(async () => { + if (!distBuilt || !vendorBuilt) return; + server = spawn(process.execPath, [resolve(ROOT, "tests-browser/server.mjs")], { + cwd: ROOT, + env: { ...process.env, PORT: String(PORT) }, + stdio: "ignore", + }); + await waitForServer(); +}, 30_000); + +afterAll(() => { + server?.kill(); +}); + +/** Every `from "…"` / bare `import "…"` specifier in an ES module. */ +function moduleSpecifiers(source: string): string[] { + const out: string[] = []; + for (const m of source.matchAll(/(?:^|[\s;}])(?:import|export)[\s\S]{0,400}?from\s*["']([^"']+)["']/g)) { + out.push(m[1]); + } + for (const m of source.matchAll(/(?:^|[\s;}])import\s*["']([^"']+)["']/g)) out.push(m[1]); + return out; +} + +describe.skipIf(!distBuilt || !vendorBuilt)("chess example — production output is servable", () => { + it("serves the directory URL as the example page", async () => { + // The classic deployment failure: `/examples/chess/` resolving to a + // directory and answering 404. + const res = await fetch(`${BASE}/examples/chess/`); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("text/html"); + + const html = await res.text(); + expect(html).toContain('data-sibu-island="chess"'); + // Two boards, 64 server-rendered squares each — present before any script runs. + expect(html.match(/data-square="/g) ?? []).toHaveLength(128); + expect(html).toContain('