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), + }, +});