From 95db6cebccc65caa4cacc43c0ad1647a55d7ffef Mon Sep 17 00:00:00 2001 From: hexplus Date: Sat, 28 Mar 2026 15:11:54 -0600 Subject: [PATCH 1/6] Updated CHANGELOG and package.json --- CHANGELOG.md | 16 ++++++++++++++++ package.json | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c767a55..64c424d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,22 @@ This project follows [Semantic Versioning](https://semver.org/). --- +## [1.0.3] — 2026-03-28 + +### Added + +- **Wider `NodeChild` / `NodeChildren` types** — `NodeChild` now accepts `boolean`; `NodeChildren` accepts nested arrays and full reactive functions. Conditional patterns like `condition && element` work without `as any` casts. Boolean values are filtered out in `appendChildren`, `bindChildNode`, `Fragment()`, `htm.ts`, and `resolveChild`. +- **`onCleanup()` lifecycle hook** — `onCleanup(callback, element)` registers teardown logic (closing sockets, clearing timers, removing listeners) tied to an element's disposal. Integrates with the existing `dispose()` system so cleanup runs automatically when `when()`, `match()`, or `each()` swap content. +- **`query()` `select` option** — Optional `select` function that transforms cached data before returning it to consumers. Raw response stays in cache; `select` runs on read, enabling derived views without extra signals. +- **`formatNumber()` and `formatCurrency()`** — `Intl`-based formatting utilities exported from `sibujs/browser`. `formatNumber` wraps `Intl.NumberFormat`; `formatCurrency` is a convenience shorthand that sets `style: "currency"`. + +### Fixed + +- **Boolean values no longer render as text** — `false`, `true` are filtered in all rendering paths (`tagFactory`, `bindChildNode`, `Fragment`, `htm.ts`, `resolveChild`) preventing visible `"false"` text nodes. +- **Lint fixes** — Resolved unused variable in `router.basic.test.ts` and formatting issues flagged by Biome. + +--- + ## [1.0.2] — 2026-03-27 ### Fixed diff --git a/package.json b/package.json index 4a30d20..a3cd741 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sibujs", - "version": "1.0.2", + "version": "1.0.3", "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", From 9487727c338809848170d361ea8775a3fa149ad9 Mon Sep 17 00:00:00 2001 From: hexplus Date: Sat, 28 Mar 2026 22:30:29 -0600 Subject: [PATCH 2/6] ci: use npm install instead of npm ci --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index aab4d99..e156d9e 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -18,7 +18,7 @@ jobs: registry-url: "https://registry.npmjs.org" - name: Install dependencies - run: npm ci + run: npm install - name: Run tests run: npm test From 077718418208d14423f9aeddb63876ce57f6454c Mon Sep 17 00:00:00 2001 From: hexplus Date: Sat, 28 Mar 2026 22:51:26 -0600 Subject: [PATCH 3/6] trusted-publisher --- .github/workflows/publish.yml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index cdf4e5b..f25d1f3 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -4,17 +4,21 @@ on: release: types: [published] +permissions: + id-token: write + contents: read + jobs: publish: runs-on: ubuntu-latest steps: - - name: Checkout código + - name: Checkout code uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: "20" + node-version: "22" registry-url: "https://registry.npmjs.org" - name: Install dependencies @@ -28,5 +32,3 @@ jobs: - name: Publish to npm run: npm publish --access public - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} From ee7cf487a4e8438c2238b7ed54bc652e48b10b6d Mon Sep 17 00:00:00 2001 From: hexplus Date: Sat, 11 Apr 2026 09:51:07 -0600 Subject: [PATCH 4/6] Updated main --- README.md | 54 +++++++++++++++++++++++++++++------------------------- 1 file changed, 29 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 633c67a..61c59bc 100644 --- a/README.md +++ b/README.md @@ -25,15 +25,10 @@ import { div, h1, button, signal, mount } from "sibujs"; function Counter() { const [count, setCount] = signal(0); - return div({ - nodes: [ - h1({ nodes: () => `Count: ${count()}` }), - button({ - nodes: "Increment", - on: { click: () => setCount(c => c + 1) } - }) - ] - }); + return div({ class: "counter" }, [ + h1(() => `Count: ${count()}`), + button({ on: { click: () => setCount(c => c + 1) } }, "Increment"), + ]); } mount(Counter, document.getElementById("app")); @@ -43,32 +38,41 @@ mount(Counter, document.getElementById("app")); SibuJS gives you maximum flexibility with three interoperable styles: -#### 1. Tag Factory (Full Props) -Maximum control with an explicit properties object. Perfect for complex elements. +#### 1. Tag Factory +The canonical form: a props object followed by children as a second +positional argument. No `nodes:` key required at any level of the tree — +children can be a string, a number, a single node, an array, or a +reactive getter. ```javascript -import { div, h1, button } from "sibujs"; - -const [count, setCount] = signal(0); - -return div({ - class: "counter", - nodes: [ - h1({ nodes: () => `Count: ${count()}` }), - button({ nodes: "Increment", on: { click: () => setCount(c => c + 1) } }) - ] -}); +import { div, h1, label, input, button } from "sibujs"; + +return div({ class: "counter" }, [ + h1({ class: "title" }, () => `Count: ${count()}`), + label({ for: "amount" }, "Step"), + input({ id: "amount", type: "number", value: 1 }), + button( + { class: "primary", on: { click: () => setCount(c => c + 1) } }, + "Increment", + ), +]); ``` -#### 2. Shorthand API -Concise and readable for common layouts. Class and children passed as positional arguments. +All legacy forms — `tag({ class, nodes })`, `tag("className", children)`, +`tag("text")`, `tag([children])`, `tag(node)`, `tag(() => reactive)` — +continue to work unchanged. When both `props.nodes` and the positional +second argument are present, the positional wins. + +#### 2. Positional Shorthand +The tersest form. Class and children as positional arguments, for +layouts with no event handlers or custom props. ```javascript import { div, h1, button } from "sibujs"; return div("counter", [ h1(() => `Count: ${count()}`), - button({ nodes: "Increment", on: { click: () => setCount(c => c + 1) } }) + button({ on: { click: () => setCount(c => c + 1) } }, "Increment"), ]); ``` From e316ae0b618d8b8ace8679c8156226e8cc6b2561 Mon Sep 17 00:00:00 2001 From: hexplus Date: Fri, 29 May 2026 17:31:50 -0600 Subject: [PATCH 5/6] Missing update package.json --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 3de3c54..176a247 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sibujs", - "version": "3.0.0", + "version": "3.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", From a662d59e702ed66c7baad1721be5badb676fd4ce Mon Sep 17 00:00:00 2001 From: hexplus Date: Fri, 26 Jun 2026 04:39:40 -0600 Subject: [PATCH 6/6] fix: harden createId & ssr-context vs duplicate runtimes; build version stamp; bench harness (3.3.2) --- .gitignore | 3 +- CHANGELOG.md | 15 +++ bench-baseline.json | 210 +++++++++++++++---------------- bench.mjs | 42 +++++-- package.json | 2 +- src/core/rendering/createId.ts | 15 ++- src/core/ssr-context.ts | 72 +++++++---- tests/duplicate-instance.test.ts | 35 ++++++ tsup.config.ts | 18 +++ 9 files changed, 267 insertions(+), 145 deletions(-) create mode 100644 tsup.config.ts diff --git a/.gitignore b/.gitignore index 96ae449..9d4e435 100644 --- a/.gitignore +++ b/.gitignore @@ -25,4 +25,5 @@ vitest-cache/ # Temporary files *.tmp *.swp -notes.txt \ No newline at end of file +notes.txt +TODO.md \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 51b072f..0f85ac8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,21 @@ This project follows [Semantic Versioning](https://semver.org/). --- +## [3.3.2] — 2026-06-26 + +Continues the duplicate-runtime hardening from 3.3.1. No breaking changes; no API or behavior change for normal usage. + +### Fixed + +- **`createId()` no longer collides across a duplicated runtime** — the unique-id counter is now shared across copies (the same first-copy-wins mechanism 3.3.1 introduced for the reactive core), so two copies can't both hand out `sibu-1`. This prevents broken a11y associations (`aria-labelledby`, `for` + `id`) and SSR hydration mismatches when a bundler duplicates the module. +- **SSR mode is consistent across a duplicated runtime** — the SSR flag/context (AsyncLocalStorage + fallback store) is now shared, so `enableSSR()` taking effect in one copy is observed by `isSSR()` in another. Previously a split could let client-only side effects run during a server render, or leak per-request state across copies. + +### Changed + +- **Duplicate-runtime dev warning now reports real versions** — the warning added in 3.3.1 now prints the actual package version (e.g. `active: 3.3.2, duplicate: …`) instead of `dev`, making mixed-version duplication easier to diagnose. Dev-only; stripped from production builds. + +--- + ## [3.3.1] — 2026-06-26 A robustness release. No breaking changes; no API or behavior change for normal usage. diff --git a/bench-baseline.json b/bench-baseline.json index 44dab23..5a44bab 100644 --- a/bench-baseline.json +++ b/bench-baseline.json @@ -1,254 +1,254 @@ [ { "name": "Create 100,000 signals", - "elapsed": 11.18, + "elapsed": 17.49, "iterations": 10, - "perOp": 1.118, - "opsPerSec": 895 + "perOp": 1.749, + "opsPerSec": 572 }, { "name": "500,000 reads", - "elapsed": 9.85, + "elapsed": 9.09, "iterations": 10, - "perOp": 0.985, - "opsPerSec": 1015 + "perOp": 0.909, + "opsPerSec": 1100 }, { "name": "500,000 writes (no subscribers)", - "elapsed": 44.57, + "elapsed": 35.18, "iterations": 10, - "perOp": 4.457, - "opsPerSec": 224 + "perOp": 3.518, + "opsPerSec": 284 }, { "name": "Create 10,000 computed from 1 signal", - "elapsed": 11.2, + "elapsed": 21.64, "iterations": 5, - "perOp": 2.24, - "opsPerSec": 446 + "perOp": 4.328, + "opsPerSec": 231 }, { "name": "Propagate through 1,000-deep computed chain", - "elapsed": 56.86, + "elapsed": 183.61, "iterations": 5, - "perOp": 11.373, - "opsPerSec": 88 + "perOp": 36.722, + "opsPerSec": 27 }, { "name": "Create 10,000 effects on 1 signal", - "elapsed": 3.76, - "iterations": 1, - "perOp": 3.76, - "opsPerSec": 266 + "elapsed": 56.8, + "iterations": 12, + "perOp": 4.734, + "opsPerSec": 211 }, { "name": "Trigger 10,000 effects (1 signal update)", - "elapsed": 104.39, + "elapsed": 129.13, "iterations": 100, - "perOp": 1.044, - "opsPerSec": 958 + "perOp": 1.291, + "opsPerSec": 774 }, { "name": "Notify 5,000 watchers per update", - "elapsed": 18.7, + "elapsed": 173.89, "iterations": 200, - "perOp": 0.094, - "opsPerSec": 10693 + "perOp": 0.869, + "opsPerSec": 1150 }, { "name": "1,000 updates WITHOUT batch", - "elapsed": 1052.13, + "elapsed": 133.43, "iterations": 10, - "perOp": 105.213, - "opsPerSec": 10 + "perOp": 13.343, + "opsPerSec": 75 }, { "name": "1,000 updates WITH batch", - "elapsed": 3.5, + "elapsed": 3.19, "iterations": 10, - "perOp": 0.35, - "opsPerSec": 2860 + "perOp": 0.319, + "opsPerSec": 3130 }, { "name": "Create 10,000
elements (no props)", - "elapsed": 77.6, + "elapsed": 69.94, "iterations": 10, - "perOp": 7.76, - "opsPerSec": 129 + "perOp": 6.994, + "opsPerSec": 143 }, { "name": "Create 10,000
with props", - "elapsed": 722.79, + "elapsed": 723.16, "iterations": 10, - "perOp": 72.279, + "perOp": 72.316, "opsPerSec": 14 }, { "name": "Create 10,000 nested trees (div > span + span)", - "elapsed": 275.64, + "elapsed": 284.46, "iterations": 5, - "perOp": 55.128, + "perOp": 56.893, "opsPerSec": 18 }, { "name": "props obj: 10,000
", - "elapsed": 92.07, + "elapsed": 82.48, "iterations": 10, - "perOp": 9.207, - "opsPerSec": 109 + "perOp": 8.248, + "opsPerSec": 121 }, { "name": "shorthand: 10,000
", - "elapsed": 86.14, + "elapsed": 82.17, "iterations": 10, - "perOp": 8.614, - "opsPerSec": 116 + "perOp": 8.217, + "opsPerSec": 122 }, { "name": "html tmpl: 10,000
", - "elapsed": 87.23, + "elapsed": 85.51, "iterations": 10, - "perOp": 8.723, - "opsPerSec": 115 + "perOp": 8.551, + "opsPerSec": 117 }, { "name": "props obj: 10,000

", - "elapsed": 258.98, + "elapsed": 269.64, "iterations": 10, - "perOp": 25.898, - "opsPerSec": 39 + "perOp": 26.964, + "opsPerSec": 37 }, { "name": "shorthand: 10,000

", - "elapsed": 252.9, + "elapsed": 268.18, "iterations": 10, - "perOp": 25.29, - "opsPerSec": 40 + "perOp": 26.818, + "opsPerSec": 37 }, { "name": "html tmpl: 10,000

", - "elapsed": 262.8, + "elapsed": 275.27, "iterations": 10, - "perOp": 26.28, - "opsPerSec": 38 + "perOp": 27.527, + "opsPerSec": 36 }, { "name": "props obj: 10,000

dyn attrs", - "elapsed": 361.11, + "elapsed": 375.92, "iterations": 10, - "perOp": 36.111, - "opsPerSec": 28 + "perOp": 37.592, + "opsPerSec": 27 }, { "name": "shorthand: 10,000
dyn attrs", - "elapsed": 344.94, + "elapsed": 374.92, "iterations": 10, - "perOp": 34.494, - "opsPerSec": 29 + "perOp": 37.492, + "opsPerSec": 27 }, { "name": "html tmpl: 10,000
dyn attrs", - "elapsed": 365.7, + "elapsed": 383.37, "iterations": 10, - "perOp": 36.57, - "opsPerSec": 27 + "perOp": 38.337, + "opsPerSec": 26 }, { "name": "props obj: 10,000 nested", - "elapsed": 270.14, + "elapsed": 293.14, "iterations": 5, - "perOp": 54.028, - "opsPerSec": 19 + "perOp": 58.628, + "opsPerSec": 17 }, { "name": "shorthand: 10,000 nested", - "elapsed": 263.14, + "elapsed": 295.58, "iterations": 5, - "perOp": 52.629, - "opsPerSec": 19 + "perOp": 59.117, + "opsPerSec": 17 }, { "name": "html tmpl: 10,000 nested", - "elapsed": 261.79, + "elapsed": 282.94, "iterations": 5, - "perOp": 52.357, - "opsPerSec": 19 + "perOp": 56.589, + "opsPerSec": 18 }, { "name": "props obj: 10,000 deep+event", - "elapsed": 512.73, + "elapsed": 520.85, "iterations": 5, - "perOp": 102.546, + "perOp": 104.17, "opsPerSec": 10 }, { "name": "shorthand: 10,000 deep+event", - "elapsed": 500.25, + "elapsed": 538.6, "iterations": 5, - "perOp": 100.05, - "opsPerSec": 10 + "perOp": 107.719, + "opsPerSec": 9 }, { "name": "html tmpl: 10,000 deep+event", - "elapsed": 732.1, + "elapsed": 725.37, "iterations": 5, - "perOp": 146.419, + "perOp": 145.074, "opsPerSec": 7 }, { "name": "10,000 reactive class updates", - "elapsed": 53.84, + "elapsed": 60.88, "iterations": 5, - "perOp": 10.769, - "opsPerSec": 93 + "perOp": 12.175, + "opsPerSec": 82 }, { "name": "Append 1,000 items to 1,000-item list", - "elapsed": 2430.99, + "elapsed": 3398.24, "iterations": 50, - "perOp": 48.62, - "opsPerSec": 21 + "perOp": 67.965, + "opsPerSec": 15 }, { "name": "Reverse 1,000-item list", - "elapsed": 1443.45, + "elapsed": 1753.52, "iterations": 100, - "perOp": 14.434, - "opsPerSec": 69 + "perOp": 17.535, + "opsPerSec": 57 }, { "name": "Remove every 2nd item from 2,000-item list", - "elapsed": 2.79, + "elapsed": 6.11, "iterations": 50, - "perOp": 0.056, - "opsPerSec": 17952 + "perOp": 0.122, + "opsPerSec": 8187 }, { "name": "Shuffle 1,000-item list (random reorder)", - "elapsed": 2344.03, + "elapsed": 2499.27, "iterations": 100, - "perOp": 23.44, - "opsPerSec": 43 + "perOp": 24.993, + "opsPerSec": 40 }, { "name": "Clear 5,000-item list", - "elapsed": 0.02, + "elapsed": 0.03, "iterations": 50, - "perOp": 0, - "opsPerSec": 2049180 + "perOp": 0.001, + "opsPerSec": 1672241 }, { "name": "Diamond graph: 10,000 root updates", - "elapsed": 29.39, + "elapsed": 24.03, "iterations": 5, - "perOp": 5.879, - "opsPerSec": 170 + "perOp": 4.807, + "opsPerSec": 208 }, { "name": "Wide diamond (500 branches): 1,000 root updates", - "elapsed": 92.48, + "elapsed": 156.86, "iterations": 5, - "perOp": 18.496, - "opsPerSec": 54 + "perOp": 31.372, + "opsPerSec": 32 } ] \ No newline at end of file diff --git a/bench.mjs b/bench.mjs index dd9d862..0169268 100644 --- a/bench.mjs +++ b/bench.mjs @@ -211,14 +211,40 @@ section("4. Effect Tracking (effect)"); let effectCount = 0; const cleanups = []; - results.push( - runBench(`Create ${fmt(N_EFFECTS)} effects on 1 signal`, () => { - for (let i = 0; i < N_EFFECTS; i++) { - cleanups.push(effect(() => { get(); effectCount++; })); - } - }, { iterations: 1 }) - ); - printResult(results.at(-1)); + // Create-only cost, measured trustworthily: each round creates N_EFFECTS on a + // FRESH throwaway signal and disposes them OUTSIDE the timed region, so we + // measure creation alone, warmed (WARM rounds discarded) and averaged over + // ROUNDS. The previous form ran a single un-warmed shot (`iterations: 1`), + // which swung ~10→20 ms run to run and made `bench:check` untrustworthy here. + { + const ROUNDS = 12; + const WARM = 3; + let elapsed = 0; + for (let r = 0; r < WARM + ROUNDS; r++) { + const [cget] = signal(0); + const tmp = new Array(N_EFFECTS); + const t = performance.now(); + for (let i = 0; i < N_EFFECTS; i++) tmp[i] = effect(() => { cget(); }); + const dt = performance.now() - t; + if (r >= WARM) elapsed += dt; + for (let i = 0; i < N_EFFECTS; i++) tmp[i](); // dispose OUTSIDE timing + } + const result = { + name: `Create ${fmt(N_EFFECTS)} effects on 1 signal`, + elapsed, + iterations: ROUNDS, + perOp: elapsed / ROUNDS, + opsPerSec: (ROUNDS / elapsed) * 1000, + }; + results.push(result); + printResult(result); + } + + // Set up the live effects on `get` that the Trigger bench below measures + // (untimed — this is fixture setup, not part of any benchmark). + for (let i = 0; i < N_EFFECTS; i++) { + cleanups.push(effect(() => { get(); effectCount++; })); + } // Update signal → triggers all effects effectCount = 0; diff --git a/package.json b/package.json index a150ee8..33001b6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sibujs", - "version": "3.3.1", + "version": "3.3.2", "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", diff --git a/src/core/rendering/createId.ts b/src/core/rendering/createId.ts index 5cb7f18..8d42606 100644 --- a/src/core/rendering/createId.ts +++ b/src/core/rendering/createId.ts @@ -1,4 +1,11 @@ -let idCounter = 0; +// The id counter is shared across duplicate copies of this module (as a bundler +// can produce under dependency pre-bundling) via a globalThis registry. Without +// this, two copies would each count from 0 and hand out colliding ids like +// `sibu-1` — breaking a11y pairing (aria-labelledby / for+id) and SSR hydration. +// First copy creates the holder; later copies reuse it. +const COUNTER_KEY = Symbol.for("sibujs.createId.v1"); +const _counter: { n: number } = ((globalThis as typeof globalThis & { [COUNTER_KEY]?: { n: number } })[COUNTER_KEY] ??= + { n: 0 }); /** * Generate a stable, framework-unique ID string suitable for a11y pairing @@ -24,8 +31,8 @@ let idCounter = 0; * ``` */ export function createId(prefix = "sibu"): string { - idCounter++; - return `${prefix}-${idCounter}`; + _counter.n++; + return `${prefix}-${_counter.n}`; } /** @@ -35,5 +42,5 @@ export function createId(prefix = "sibu"): string { * @internal */ export function __resetIdCounter(): void { - idCounter = 0; + _counter.n = 0; } diff --git a/src/core/ssr-context.ts b/src/core/ssr-context.ts index ac2694c..e964acc 100644 --- a/src/core/ssr-context.ts +++ b/src/core/ssr-context.ts @@ -39,36 +39,56 @@ type ALSLike = { run(store: T, fn: () => R): R; }; -let als: ALSLike | null = null; -// One-time runtime detection of AsyncLocalStorage. Exactly one branch runs per -// environment (Node-with-getBuiltinModule, Node-CommonJS, or non-Node), so the -// other branches are unreachable in any single coverage run — excluded here. -/* v8 ignore start */ -try { - if (typeof process !== "undefined" && process.versions && process.versions.node) { - type AHMod = { AsyncLocalStorage: new () => ALSLike }; - let mod: AHMod | null = null; - // Prefer process.getBuiltinModule (Node 22.3+): synchronous AND works under - // ESM. The `require`-based path below only works in CommonJS, so under ESM - // bundles (the common SSR setup) ALS would silently never load and the - // per-request SSR scope (flag + query cache) would fall back to a shared - // module global — i.e. cross-request data bleed. getBuiltinModule fixes that. - const getBuiltin = (process as unknown as { getBuiltinModule?: (id: string) => unknown }).getBuiltinModule; - if (typeof getBuiltin === "function") { - mod = getBuiltin("node:async_hooks") as AHMod; - } else { - const req = (Function("return typeof require==='function'?require:null") as () => NodeRequire | null)(); - if (req) mod = req("node:async_hooks") as AHMod; +// The AsyncLocalStorage instance and the fallback store are shared across +// duplicate copies of this module (as a bundler can produce under dependency +// pre-bundling) via a globalThis registry. Without sharing, each copy would +// keep its own `als`/`fallbackStore`, so `enableSSR()` in one copy would not be +// seen by `isSSR()` in another — letting effects run on the server, or leaking +// per-request state. The first copy runs the (one-time) ALS detection and +// publishes the shared state; later copies reuse it. +interface SSRShared { + als: ALSLike | null; + fallbackStore: SSRStore; +} +const SSR_KEY = Symbol.for("sibujs.ssr.v1"); + +function detectSSRShared(): SSRShared { + let detected: ALSLike | null = null; + // One-time runtime detection of AsyncLocalStorage. Exactly one branch runs per + // environment (Node-with-getBuiltinModule, Node-CommonJS, or non-Node), so the + // other branches are unreachable in any single coverage run — excluded here. + /* v8 ignore start */ + try { + if (typeof process !== "undefined" && process.versions && process.versions.node) { + type AHMod = { AsyncLocalStorage: new () => ALSLike }; + let mod: AHMod | null = null; + // Prefer process.getBuiltinModule (Node 22.3+): synchronous AND works under + // ESM. The `require`-based path below only works in CommonJS, so under ESM + // bundles (the common SSR setup) ALS would silently never load and the + // per-request SSR scope (flag + query cache) would fall back to a shared + // module global — i.e. cross-request data bleed. getBuiltinModule fixes that. + const getBuiltin = (process as unknown as { getBuiltinModule?: (id: string) => unknown }).getBuiltinModule; + if (typeof getBuiltin === "function") { + mod = getBuiltin("node:async_hooks") as AHMod; + } else { + const req = (Function("return typeof require==='function'?require:null") as () => NodeRequire | null)(); + if (req) mod = req("node:async_hooks") as AHMod; + } + if (mod) detected = new mod.AsyncLocalStorage(); } - if (mod) als = new mod.AsyncLocalStorage(); + } catch { + detected = null; } -} catch { - als = null; + /* v8 ignore stop */ + return { als: detected, fallbackStore: { ssr: false, suspenseIdCounter: 0 } }; } -/* v8 ignore stop */ -// Fallback store used when AsyncLocalStorage is unavailable. -const fallbackStore: SSRStore = { ssr: false, suspenseIdCounter: 0 }; +const _shared: SSRShared = ((globalThis as typeof globalThis & { [SSR_KEY]?: SSRShared })[SSR_KEY] ??= + detectSSRShared()); +// Stable module-local aliases: `als` is never reassigned after detection, and +// `fallbackStore` is a shared object every copy mutates/reads in place. +const als = _shared.als; +const fallbackStore = _shared.fallbackStore; /** Returns the active store (ALS or fallback). */ export function getSSRStore(): SSRStore { diff --git a/tests/duplicate-instance.test.ts b/tests/duplicate-instance.test.ts index db36ef4..9b39800 100644 --- a/tests/duplicate-instance.test.ts +++ b/tests/duplicate-instance.test.ts @@ -24,6 +24,10 @@ interface Instance { signal: (v: T) => [() => T, (n: T | ((p: T) => T)) => void]; reactiveBinding: (commit: () => void) => () => void; batch: (fn: () => T) => T; + createId: (prefix?: string) => string; + enableSSR: () => void; + disableSSR: () => void; + isSSR: () => boolean; } const REGISTRY_KEY = Symbol.for("sibujs.reactive.v1"); @@ -45,6 +49,8 @@ beforeAll(async () => { export { signal } from "./src/core/signals/signal"; export { reactiveBinding } from "./src/reactivity/track"; export { batch } from "./src/reactivity/batch"; + export { createId } from "./src/core/rendering/createId"; + export { enableSSR, disableSSR, isSSR } from "./src/core/ssr-context"; `, resolveDir: process.cwd(), loader: "ts", @@ -135,3 +141,32 @@ describe("duplicate reactive runtime instances", () => { expect(dupWarnings).toHaveLength(1); }); }); + +describe("duplicate instance — other coordination singletons", () => { + test("createId yields a continuous, non-colliding sequence across instances", () => { + const a = loadInstance(); + const b = loadInstance(); + + // A shared counter means the two copies never hand out the same id — + // critical for a11y pairing (aria-labelledby / for+id) and SSR hydration. + // Independent module-local counters would both start at 1 and collide. + const ids = [a.createId("x"), b.createId("x"), a.createId("x"), b.createId("x")]; + expect(new Set(ids).size).toBe(ids.length); // all unique + }); + + test("enableSSR() in instance A is observed by isSSR() in instance B", () => { + const a = loadInstance(); + const b = loadInstance(); + + expect(b.isSSR()).toBe(false); + a.enableSSR(); + try { + // Split SSR state would let an effect created via instance B run on the + // server (B still thinks it's the client), or leak request state. + expect(b.isSSR()).toBe(true); + } finally { + a.disableSSR(); + } + expect(b.isSSR()).toBe(false); + }); +}); diff --git a/tsup.config.ts b/tsup.config.ts new file mode 100644 index 0000000..600aa53 --- /dev/null +++ b/tsup.config.ts @@ -0,0 +1,18 @@ +import { readFileSync } from "node:fs"; +import { defineConfig } from "tsup"; + +// Read the package version so the reactive runtime can stamp it onto the +// duplicate-instance registry (see src/reactivity/track.ts). `__SIBU_VERSION__` +// is a bundler define — under raw ESM / the test runner it is undefined and the +// runtime falls back to "dev". This config is auto-loaded by every `tsup` +// invocation in the build script (main + CDN), so the stamp lands in all +// outputs. Entry points / formats stay on the CLI; this only adds `define`. +const { version } = JSON.parse(readFileSync(new URL("./package.json", import.meta.url), "utf8")) as { + version: string; +}; + +export default defineConfig({ + define: { + __SIBU_VERSION__: JSON.stringify(version), + }, +});