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