From 95db6cebccc65caa4cacc43c0ad1647a55d7ffef Mon Sep 17 00:00:00 2001 From: hexplus Date: Sat, 28 Mar 2026 15:11:54 -0600 Subject: [PATCH 1/5] 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/5] 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/5] 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/5] 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 3205cbf61b968ab347bae0e8bcbe8775be7214d7 Mon Sep 17 00:00:00 2001 From: hexplus Date: Sat, 18 Apr 2026 20:31:02 -0600 Subject: [PATCH 5/5] perf(reactivity): linked-list subscription graph, O(1) dup detection (2.2.0) --- CHANGELOG.md | 47 ++ package.json | 2 +- src/core/signals/derived.ts | 31 +- src/core/signals/effect.ts | 295 +++++++----- src/core/signals/signal.ts | 97 +++- src/devtools/devtools.ts | 8 +- src/devtools/introspect.ts | 54 +-- src/reactivity/track.ts | 897 +++++++++++++++++++----------------- 8 files changed, 809 insertions(+), 622 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e5f27f7..5ac325e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,53 @@ This project follows [Semantic Versioning](https://semver.org/). --- +## [2.2.0] — 2026-04-18 + +Reactivity-core rewrite. Replaces the `Set` / `Map` subscription graph with doubly-linked `SubNode` edges, a node pool, and an `__activeNode` back-pointer for O(1) duplicate-dependency detection. Subscription is now O(1) on both add and remove, the hot path has no hash operations, and GC churn on create/destroy workloads drops sharply. + +**Improvements over 2.1.0 on the reactivity stress-test suite (`benchmarks/`):** + +- **Wide graph / 10k fan-out: ~73% faster** (56.8 ms → 15.4 ms) +- **Cascading effects: ~41% faster** (2.03 ms → 1.20 ms) +- **Memory & cleanup: ~21% faster** (51.3 ms → 40.6 ms) +- **Component tree propagation: ~10% faster** (23.0 ms → 20.6 ms) +- **Deep computed chain: ~7% faster** (3.87 ms → 3.60 ms) + +**201/201 test files, 2187/2187 tests passing. No breaking changes to the documented public API** — `signal`, `derived`, `effect`, `batch`, `untracked`, `on`, `setMaxDrainIterations`, `setMaxSubscriberRepeats`, devtools introspection helpers, all behave identically. + +### Added + +- **`cleanup(subscriber)`** now exported from `sibujs/reactivity/track`. Disposes a subscriber directly without allocating an intermediate closure. Enables custom effect-like primitives to manage their own lifecycle without going through `track()`'s disposer. +- **`getSubscriberCount(signal)`** — O(1) count of active subscribers, read from the `__sc` counter maintained on every subscribe/unsubscribe. +- **`getSubscriberDeps(subscriber)`** — returns the signals a subscriber currently depends on, in record order. Replaces the previous `_dep` / `_deps` probe used by devtools. +- **`forEachSubscriber(signal, visit)`** — iterate a signal's subscriber list without exposing the internal linked-list structure to callers. + +### Changed + +- **Subscription storage migrated from Set + Map to doubly-linked `SubNode` edges.** Each `(signal, subscriber)` pair is one object linked into both the signal's subscriber list and the subscriber's dep list. O(1) subscribe / unsubscribe via pointer splice, no hash operations on the hot path, one allocation per edge instead of two. +- **Duplicate-dependency detection during tracking is now O(1)** via a `signal.__activeNode` back-pointer (Preact Signals' approach). A subscriber with 10 000 deps reading one signal twice no longer pays O(N²) in its inner loop. +- **Effects now re-run via `retrack()` instead of `track()`.** Stable-dep effects (the overwhelmingly common case) skip the cleanup-and-rebuild cycle entirely — epoch-based pruning at end of run handles any deps that were dropped this invocation. On the Cascading Effects benchmark this drops per-invocation cost by ~40 ns. +- **Effect internals consolidated behind an `EffectCtx` object.** Per-effect closure count went from six (`onCleanup`, `flushUserCleanups`, `wrappedFn`, `drainReruns`, `subscriber`, `dispose`) to three. `runSubscriber` and `runBody` are inlined directly into the per-effect closures, eliminating a function frame per invocation. +- **`track()` is stack-free.** The shared `subscriberStack` array is gone; `track()` uses a local `prev` / restore pattern, and `suspend/resumeTracking` capture `currentSubscriber` directly. ~5–10 ns saved per track call, universal improvement. +- **Signal state pre-initialises every internal slot** (`__v`, `__sc`, `subsHead`, `subsTail`, `__activeNode`, `__name`) at construction. V8 hidden classes stay monomorphic across all signals; inline caches in the reactivity hot paths don't transition on first subscribe. +- **Signal setter specialised at creation time** — one closure for the default `Object.is` equality path, one for custom `equals`, one dev-mode variant carrying the devtools-hook emission. No per-call branching on the hot path. +- **Cached `track()` disposer** via `sub._dispose ??= …` — allocated once per subscriber instead of once per `track()` call. Meaningful for high-churn workloads (large lists, create/destroy cycles). +- **Node pool** (cap 4 096) recycles freed `SubNode` objects. Shape-stable allocation keeps hidden classes monomorphic; a create/destroy cycle with 25 000 effects reuses edge nodes instead of allocating and freeing them. + +### Removed + +- **`signal.__s`** — the Set-based subscriber cache. Replaced by `subsHead` / `subsTail` linked-list anchors plus `__sc` (count). External consumers should read counts via `getSubscriberCount()`. +- **`signal.__f`** — the single-subscriber fast-path cache. A one-node linked-list walk is inherently as fast as the check it was avoiding. +- **Internal `subscriberStack`** — the shared push/pop array used by the old `track()` / `suspend/resume` pair. Not observable from user code. + +### Internal + +- `introspect.ts` delegates to the new `getSubscriberCount` / `getSubscriberDeps` / `forEachSubscriber` helpers. Public API surface (`ReactiveNodeInfo`, `getSignalName`, `getDependencies`, `inspectSignal`, `walkDependencyGraph`) unchanged. +- `devtools.ts` reads `node.ref?.__sc` instead of `node.ref?.__s.size`. +- A three-color CLEAN/CHECK/DIRTY propagation model was prototyped and reverted after benchmark regression (+122% on Deep Chain). The workloads in the current suite all produce new downstream values on every signal change, so the CHECK state has no work to skip — only overhead to add. A dedicated benchmark suite for stabilisation patterns needs to come first; re-introducing three-color propagation is parked for a future release. + +--- + ## [2.1.0] — 2026-04-17 Reactivity-core hardening release. Closes correctness gaps around effect re-entry, derived stale deps, sibling-effect consistency, and cycle detection. **201/201 test files, 2187/2187 tests passing — no behavior changes to user code that was already correct.** diff --git a/package.json b/package.json index 556ab12..ca083a3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sibujs", - "version": "2.1.0", + "version": "2.2.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", diff --git a/src/core/signals/derived.ts b/src/core/signals/derived.ts index 2c80574..7defec0 100644 --- a/src/core/signals/derived.ts +++ b/src/core/signals/derived.ts @@ -6,12 +6,21 @@ import type { Accessor } from "./signal"; /** * derived creates a derived reactive signal whose value updates when dependencies change. * - * Uses lazy pull-based evaluation with dirty flagging: + * Uses lazy pull-based evaluation with a single dirty flag: * - When a dependency changes, the computed is marked dirty (no re-evaluation). * - Dirtiness propagates downstream via propagateDirty. * - The getter only re-evaluates when actually read (pull-based). - * - On re-evaluation, dependencies are re-tracked via track() so that - * derived-of-derived chains propagate correctly. + * - On re-evaluation, dependencies are re-tracked via retrack() so that + * derived-of-derived chains propagate correctly without paying the full + * Set-delete + re-add cost of track()'s cleanup phase. + * + * NOTE: a previous revision experimented with three-color (CLEAN/CHECK/DIRTY) + * state for read-side value-change short-circuiting. It regressed every + * benchmark except Memory (Deep Chain +122%, Component Tree +20%) because + * the workloads always produce a new downstream value and CHECK had no + * work to skip — only overhead to add. Keeping the simpler boolean flag + * here; revisit CHECK propagation when we have benchmarks that exercise + * stabilisation on diamond / conditional-branch patterns. */ export function derived( getter: () => T, @@ -28,6 +37,10 @@ export function derived( const cs: any = {}; cs._d = false; cs._g = getter; + // __v: monotonic version counter, bumped only when re-evaluation produces + // a value different from the previous (Object.is comparison). Kept on the + // computed so future read-side short-circuit work can compare against it. + cs.__v = 0; const markDirty = (): void => { if (cs._d) return; @@ -66,14 +79,14 @@ export function derived( evaluating = true; let threw = true; try { - // Use retrack (epoch-based sweep) so steady-state chains skip - // Set.delete+add cycles on every pull — only deps that actually - // changed do Set mutations. + const prev = cs._v; retrack(() => { - cs._v = getter(); + const next = getter(); + cs._v = equals && cs._v !== undefined ? (equals(cs._v, next) ? cs._v : next) : next; cs._d = false; threw = false; }, markDirty); + if (!Object.is(prev, cs._v)) cs.__v++; } finally { evaluating = false; if (threw) cs._d = true; @@ -91,9 +104,6 @@ export function derived( evaluating = true; let threw = true; try { - // retrack (no cleanup, no stack array push) so new conditional - // deps acquired by the getter still subscribe markDirty. Steady- - // state chains skip the Set.delete+add cycle that track() incurs. retrack(() => { const next = getter(); // If caller provided a custom equality fn and the value didn't @@ -104,6 +114,7 @@ export function derived( cs._d = false; threw = false; }, markDirty); + if (!Object.is(oldValue, cs._v)) cs.__v++; } finally { evaluating = false; if (threw) cs._d = true; diff --git a/src/core/signals/effect.ts b/src/core/signals/effect.ts index 07138da..afe87f6 100644 --- a/src/core/signals/effect.ts +++ b/src/core/signals/effect.ts @@ -1,4 +1,4 @@ -import { track, untracked } from "../../reactivity/track"; +import { cleanup as coreCleanup, retrack, untracked } from "../../reactivity/track"; import { devAssert } from "../dev"; import { isSSR } from "../ssr-context"; @@ -56,6 +56,107 @@ export type OnCleanup = (fn: () => void) => void; * teardown that runs before the next re-run or on dispose. */ export type EffectBody = (onCleanup: OnCleanup) => void; +// --------------------------------------------------------------------------- +// Effect implementation — context-object design. +// +// Each `effect()` call allocates ONE `EffectCtx` plus three closures: +// - ctx.onCleanup (user-exposed, captures ctx) +// - ctx.subscriber (tracking entry point, captures ctx) +// - the returned dispose handle (captures ctx) +// +// Every other function is module-level — a single shared instance that reads +// per-effect state out of the passed ctx. Previously we allocated six closures +// per effect (onCleanup, flushUserCleanups, wrappedFn, drainReruns, +// subscriber, dispose). For the Memory benchmark (25 000 effect creations per +// run) this saves ~75 000 closure allocations, a measurable chunk of GC. +// --------------------------------------------------------------------------- + +// Safety cap — if an effect keeps requesting re-runs, bail rather than loop +// forever. Matches the spirit of drainNotificationQueue's cap. +const MAX_RERUNS = 100; + +interface EffectCtx { + fn: EffectBody | (() => void); + onError: ((err: unknown) => void) | undefined; + userCleanups: Array<() => void>; + running: boolean; + rerunPending: boolean; + disposed: boolean; + onCleanup: OnCleanup; + subscriber: () => void; + // Pre-allocated body closure passed to track(). Allocated ONCE at effect + // creation and reused across every invocation — avoids allocating a fresh + // `() => runBody(ctx)` on every re-run, which for a 10k-invocation + // workload would cost ~10k closure allocations. + bodyFn: () => void; +} + +function flushUserCleanups(ctx: EffectCtx): void { + const list = ctx.userCleanups; + if (list.length === 0) return; + ctx.userCleanups = []; + for (let i = list.length - 1; i >= 0; i--) { + try { + list[i](); + } catch (err) { + if (typeof console !== "undefined") console.warn("[SibuJS effect] onCleanup threw:", err); + } + } +} + +// Cold path: an effect wrote to a signal it depends on mid-body, triggering +// rerunPending. Loop until stable or the safety cap trips. Kept module-level +// because it's rare and larger than the hot path. +function drainReruns(ctx: EffectCtx): void { + let reruns = 1; + do { + ctx.rerunPending = false; + if (ctx.userCleanups.length > 0) flushUserCleanups(ctx); + retrack(ctx.bodyFn, ctx.subscriber); + } while (ctx.rerunPending && ++reruns <= MAX_RERUNS); + if (ctx.rerunPending) { + ctx.rerunPending = false; + if (_g.__SIBU_DEV_WARN__ !== false && typeof console !== "undefined") { + console.error( + `[SibuJS] effect re-requested itself ${MAX_RERUNS}+ times — ` + + "likely a write-reads-self cycle. Breaking to prevent infinite loop.", + ); + } + } +} + +function disposeEffect(ctx: EffectCtx): void { + // Idempotent — user code composing disposers (Array.push(dispose)) may + // inadvertently call twice. Second call should be a no-op. + if (ctx.disposed) return; + ctx.disposed = true; + const h = _g.__SIBU_DEVTOOLS_GLOBAL_HOOK__; + if (h) { + try { + h.emit("effect:destroy", { effectFn: ctx.fn }); + } catch { + /* devtools hook errors should not break user teardown */ + } + } + try { + if (ctx.userCleanups.length > 0) flushUserCleanups(ctx); + } catch (err) { + if (typeof console !== "undefined") { + console.warn("[SibuJS effect] onCleanup threw during dispose:", err); + } + } + try { + // Call core cleanup directly on the subscriber — no per-subscriber + // closure allocation, which matters when many effects are disposed + // in bulk (Memory benchmark). + coreCleanup(ctx.subscriber); + } catch (err) { + if (typeof console !== "undefined") { + console.warn("[SibuJS effect] dispose threw:", err); + } + } +} + /** * effect runs the provided effectFn immediately and re-runs it whenever * any reactive dependency changes. @@ -78,147 +179,97 @@ export function effect(effectFn: EffectBody | (() => void), options?: EffectOpti // No-op during SSR — side effects are client-only if (isSSR()) return () => {}; - const onError = options?.onError; - // Per-run cleanup callbacks registered via the onCleanup arg. Cleared and - // drained before each re-run and on dispose, in reverse registration order. - let userCleanups: Array<() => void> = []; - const onCleanup: OnCleanup = (fn) => { - userCleanups.push(fn); + // Allocate a single per-effect state object. All per-effect state lives + // here; module-level helper functions receive `ctx` as their only + // argument. Pre-initialized to keep the hidden class stable. + const ctx: EffectCtx = { + fn: effectFn, + onError: options?.onError, + userCleanups: [], + running: false, + rerunPending: false, + disposed: false, + onCleanup: null as unknown as OnCleanup, + subscriber: null as unknown as () => void, + bodyFn: null as unknown as () => void, }; - const runUserCleanups = () => { - if (userCleanups.length === 0) return; - const list = userCleanups; - userCleanups = []; - for (let i = list.length - 1; i >= 0; i--) { - try { - list[i](); - } catch (err) { - if (typeof console !== "undefined") { - console.warn("[SibuJS effect] onCleanup threw:", err); - } - } - } + ctx.onCleanup = (fn) => { + ctx.userCleanups.push(fn); }; - const invokeBody = () => (effectFn as EffectBody)(onCleanup); - - // When onError is provided, wrap the effect function in a try/catch. - // When not provided, use the raw effectFn — zero overhead for the default case. - const wrappedFn = onError + // Pre-allocated body closure passed to track(). Logic is inlined instead + // of delegating to a module-level `runBody(ctx)` — saves one function + // frame per effect invocation on the hot path. + const onErrorCaptured = ctx.onError; + ctx.bodyFn = onErrorCaptured ? () => { try { - invokeBody(); + (ctx.fn as EffectBody)(ctx.onCleanup); } catch (err) { - onError(err); + onErrorCaptured(err); } } - : invokeBody; - - let cleanupHandle: () => void = () => {}; - let running = false; - let rerunPending = false; - - // Safety cap — if an effect keeps requesting re-runs, bail rather than - // loop forever. Matches the spirit of drainNotificationQueue's cap. - const MAX_RERUNS = 100; - - const subscriber = () => { - if (running) { - // Effect wrote to a signal it depends on while still running. - // Instead of silently dropping the update (which leaves the effect's - // last-seen state out of sync with reality), flag a re-run request - // and run it after the current body finishes. This preserves the - // no-reentrant-recursion invariant while keeping state consistent. - rerunPending = true; + : () => { + (ctx.fn as EffectBody)(ctx.onCleanup); + }; + + // Subscriber closure with runSubscriber's hot-path logic INLINED. Same + // allocation count as before (one closure per effect), but one fewer + // function frame per invocation. For Cascading (4000 invocations / run) + // this shaves ~60 µs; for Memory (50k invocations / run) ~750 µs. + // + // Fields are set explicitly after allocation so every effect subscriber + // gets the same hidden class, keeping V8's inline caches monomorphic in + // track / cleanup / recordDep. + const sub = (() => { + if (ctx.running) { + ctx.rerunPending = true; return; } - running = true; + ctx.running = true; try { - let reruns = 0; - do { - rerunPending = false; - // Run user onCleanup BEFORE cleanupHandle so user teardown observes - // the reactive state from the previous run (e.g. before subs are cut). - runUserCleanups(); - cleanupHandle(); - cleanupHandle = track(wrappedFn, subscriber); - if (++reruns > MAX_RERUNS) { - if (_g.__SIBU_DEV_WARN__ !== false && typeof console !== "undefined") { - console.error( - `[SibuJS] effect re-requested itself ${MAX_RERUNS}+ times — ` + - "likely a write-reads-self cycle. Breaking to prevent infinite loop.", - ); - } - rerunPending = false; - break; - } - } while (rerunPending); + ctx.rerunPending = false; + if (ctx.userCleanups.length > 0) flushUserCleanups(ctx); + // `retrack()` reuses stable dep edges via epoch tagging — for + // effects with unchanging deps (the common case) it skips the + // unlink/alloc/relink cycle that `track()` does every invocation. + // Conditional-dep effects still work: deps not re-read this run get + // pruned at end of retrack via epoch mismatch. + retrack(ctx.bodyFn, sub); + if (ctx.rerunPending) drainReruns(ctx); } finally { - running = false; - rerunPending = false; + ctx.running = false; + ctx.rerunPending = false; } + }) as (() => void) & { + depsHead: null; + depsTail: null; + _epoch: number; + _structDirty: boolean; + _runEpoch: number; + _runs: number; + _dispose?: () => void; }; + sub.depsHead = null; + sub.depsTail = null; + sub._epoch = 0; + sub._structDirty = false; + sub._runEpoch = 0; + sub._runs = 0; + ctx.subscriber = sub; - running = true; + // Initial run — take the happy path directly (no cleanup, no userCleanups). + ctx.running = true; try { - let reruns = 0; - do { - rerunPending = false; - // On iterations > 1 we need to tear down the *previous* iteration's - // registrations before re-tracking, otherwise onCleanup callbacks - // accumulate across rerun passes and the prior track()'s - // subscriptions stay dangling until track()'s own cleanup() runs. - // No-ops on the first iteration (userCleanups empty, cleanupHandle noop). - runUserCleanups(); - cleanupHandle(); - cleanupHandle = track(wrappedFn, subscriber); - if (++reruns > MAX_RERUNS) { - if (_g.__SIBU_DEV_WARN__ !== false && typeof console !== "undefined") { - console.error( - `[SibuJS] effect re-requested itself ${MAX_RERUNS}+ times on initial run — ` + - "likely a write-reads-self cycle. Breaking to prevent infinite loop.", - ); - } - rerunPending = false; - break; - } - } while (rerunPending); + retrack(ctx.bodyFn, ctx.subscriber); + if (ctx.rerunPending) drainReruns(ctx); } finally { - running = false; - rerunPending = false; + ctx.running = false; + ctx.rerunPending = false; } const hook = _g.__SIBU_DEVTOOLS_GLOBAL_HOOK__; if (hook) hook.emit("effect:create", { effectFn }); - let disposed = false; - return () => { - // Idempotent — user code composing disposers (Array.push(dispose)) may - // inadvertently call twice. Second call should be a no-op, not re-emit - // effect:destroy or re-run cleanupHandle (which re-walks subs lists). - if (disposed) return; - disposed = true; - const h = _g.__SIBU_DEVTOOLS_GLOBAL_HOOK__; - if (h) { - try { - h.emit("effect:destroy", { effectFn }); - } catch { - /* devtools hook errors should not break user teardown */ - } - } - try { - runUserCleanups(); - } catch (err) { - if (typeof console !== "undefined") { - console.warn("[SibuJS effect] onCleanup threw during dispose:", err); - } - } - try { - cleanupHandle(); - } catch (err) { - if (typeof console !== "undefined") { - console.warn("[SibuJS effect] dispose threw:", err); - } - } - }; + return () => disposeEffect(ctx); } diff --git a/src/core/signals/signal.ts b/src/core/signals/signal.ts index f90dab7..00fb997 100644 --- a/src/core/signals/signal.ts +++ b/src/core/signals/signal.ts @@ -46,14 +46,40 @@ const _isDev = isDev(); * @param options Optional config: `{ name: "count" }` for devtools labeling */ export function signal(initial: T, options?: SignalOptions): StateTuple { - const state: { value: T } = { value: initial }; + // Pre-initialize every internal field the reactivity core touches. This + // keeps the V8 hidden class stable across all signals — inline caches in + // recordDependency / notifySubscribers / link helpers stay monomorphic + // instead of transitioning on first subscribe, first notify, etc. + // + // value — user's current value + // __v — version counter, bumped only on actual change + // __sc — subscriber count (O(1) devtools reads) + // subsHead/Tail — doubly-linked subscriber list + // __activeNode — back-pointer for O(1) dup dep detection during tracking + // __name — optional debug label + const state: { + value: T; + __v: number; + __sc: number; + subsHead: unknown; + subsTail: unknown; + __activeNode: unknown; + __name?: string; + } = { + value: initial, + __v: 0, + __sc: 0, + subsHead: null, + subsTail: null, + __activeNode: null, + __name: undefined, + }; const debugName = _isDev ? options?.name : undefined; const equalsFn = options?.equals; - // Tag signal with debug name for devtools/introspection - if (debugName) { - (state as Record).__name = debugName; - } + // Debug name is pre-declared on the state shape so the hidden class stays + // stable whether or not a name is provided. + if (debugName) state.__name = debugName; function get(): T { recordDependency(state as ReactiveSignal); @@ -64,22 +90,59 @@ export function signal(initial: T, options?: SignalOptions): StateTuple (get as unknown as Record).__signal = state; if (debugName) (get as unknown as Record).__name = debugName; - function set(next: T | ((prev: T) => T)): void { - const newValue = typeof next === "function" ? (next as (prev: T) => T)(state.value) : next; - if (equalsFn ? equalsFn(state.value, newValue) : Object.is(newValue, state.value)) return; + // --- Setter: two specialized variants (Object.is fast path vs custom equals) + // + // V8 optimizes monomorphic function shapes better than polymorphic ones. + // Signals with the default equals (Object.is) are by far the common case; + // giving them their own closure with no branch on `equalsFn` lets the JIT + // inline it. Signals with custom equals pay the extra call, same as before. + // + // Dev-mode devtools hook emission lives behind the cached `_isDev` so + // production closures don't carry the branch either. + // --------------------------------------------------------------------------- + let set: SetState; - if (_isDev) { - const oldValue = state.value; + if (equalsFn) { + set = (next) => { + const prev = state.value; + const newValue = typeof next === "function" ? (next as (p: T) => T)(prev) : next; + if (equalsFn(prev, newValue)) return; + state.value = newValue; + state.__v++; + if (_isDev) { + const hook = _g.__SIBU_DEVTOOLS_GLOBAL_HOOK__; + if (hook) hook.emit("signal:update", { signal: state, name: debugName, oldValue: prev, newValue }); + } + if (!enqueueBatchedSignal(state as ReactiveSignal)) { + notifySubscribers(state as ReactiveSignal); + } + }; + } else if (_isDev) { + set = (next) => { + const prev = state.value; + const newValue = typeof next === "function" ? (next as (p: T) => T)(prev) : next; + if (Object.is(newValue, prev)) return; state.value = newValue; + state.__v++; const hook = _g.__SIBU_DEVTOOLS_GLOBAL_HOOK__; - if (hook) hook.emit("signal:update", { signal: state, name: debugName, oldValue, newValue }); - } else { + if (hook) hook.emit("signal:update", { signal: state, name: debugName, oldValue: prev, newValue }); + if (!enqueueBatchedSignal(state as ReactiveSignal)) { + notifySubscribers(state as ReactiveSignal); + } + }; + } else { + // Production hot path — smallest possible setter. No dev hook, no custom + // equals branch, no debug-name lookup. + set = (next) => { + const prev = state.value; + const newValue = typeof next === "function" ? (next as (p: T) => T)(prev) : next; + if (Object.is(newValue, prev)) return; state.value = newValue; - } - - if (!enqueueBatchedSignal(state as ReactiveSignal)) { - notifySubscribers(state as ReactiveSignal); - } + state.__v++; + if (!enqueueBatchedSignal(state as ReactiveSignal)) { + notifySubscribers(state as ReactiveSignal); + } + }; } if (_isDev) { diff --git a/src/devtools/devtools.ts b/src/devtools/devtools.ts index dcdc37e..7f1051c 100644 --- a/src/devtools/devtools.ts +++ b/src/devtools/devtools.ts @@ -349,13 +349,14 @@ export function initDevTools(config?: DevToolsConfig) { } catch { value = ""; } - const subs = node.ref?.__s; result.push({ id: node.id, name: node.name, type: node.type, value, - subscriberCount: subs instanceof Set ? subs.size : 0, + // __sc is the O(1) subscriber count maintained by the reactivity + // core (track.ts) on every linked-list splice. + subscriberCount: (node.ref?.__sc as number | undefined) ?? 0, }); } return result; @@ -535,14 +536,13 @@ export function initDevTools(config?: DevToolsConfig) { const fullVal = val; const shortVal = val.length > 80 ? `${val.substring(0, 80)}...` : val; - const subs = node.ref?.__s; sArr.push({ id: node.id, n: node.name, tp: node.type, v: shortVal, fv: fullVal, - sc: subs instanceof Set ? subs.size : 0, + sc: (node.ref?.__sc as number | undefined) ?? 0, }); } diff --git a/src/devtools/introspect.ts b/src/devtools/introspect.ts index 13c3b7e..d71d66d 100644 --- a/src/devtools/introspect.ts +++ b/src/devtools/introspect.ts @@ -8,9 +8,11 @@ */ import type { ReactiveSignal } from "../reactivity/signal"; - -// Internal subscriber type matches track.ts -const SUBS = "__s" as const; +import { + getSubscriberCount as coreGetSubscriberCount, + forEachSubscriber, + getSubscriberDeps, +} from "../reactivity/track"; /** Info about a reactive node in the dependency graph */ export interface ReactiveNodeInfo { @@ -45,26 +47,18 @@ export function getSignalName(getter: () => unknown): string | undefined { export function getSubscriberCount(getter: () => unknown): number { const signal = (getter as unknown as Record).__signal as ReactiveSignal | undefined; if (!signal) return 0; - const subs = (signal as Record)[SUBS] as Set | undefined; - return subs ? subs.size : 0; + return coreGetSubscriberCount(signal); } /** * Get the dependency list of an effect or computed subscriber function. * Returns signal references that the subscriber depends on. * - * Note: This reads the internal dep storage that track.ts maintains on - * subscriber functions. Handles both the single-dep fast path (`_dep`) - * and the multi-dep Map (`_deps`). + * Note: This reads the linked-list dep storage maintained by track.ts. Safe + * to call on any subscriber (effect or computed markDirty). */ export function getDependencies(subscriberFn: () => void): ReactiveSignal[] { - const fn = subscriberFn as unknown as Record; - const singleDep = fn._dep as ReactiveSignal | undefined; - if (singleDep !== undefined) return [singleDep]; - const deps = fn._deps as Map | Set | undefined; - if (!deps) return []; - // Map exposes keys(); Set is iterable directly. - return deps instanceof Map ? Array.from(deps.keys()) : Array.from(deps); + return getSubscriberDeps(subscriberFn); } /** @@ -74,12 +68,10 @@ export function inspectSignal(getter: () => unknown): ReactiveNodeInfo | null { const signal = (getter as unknown as Record).__signal as ReactiveSignal | undefined; if (!signal) return null; - const subs = (signal as Record)[SUBS] as Set | undefined; - return { name: (getter as unknown as Record).__name as string | undefined, signal, - subscriberCount: subs ? subs.size : 0, + subscriberCount: coreGetSubscriberCount(signal), }; } @@ -101,26 +93,22 @@ export function walkDependencyGraph( } visited.add(signal); - const subs = (signal as Record)[SUBS] as Set<() => void> | undefined; const downstream: ReturnType[] = []; - - if (subs) { - for (const sub of subs) { - const subSig = (sub as unknown as Record)._sig as ReactiveSignal | undefined; - if (subSig && !visited.has(subSig)) { - const subName = (subSig as Record).__name; - const fakeGetter = (() => undefined) as unknown as () => unknown; - const tag = fakeGetter as unknown as Record; - tag.__signal = subSig; - if (subName !== undefined) tag.__name = subName; - downstream.push(walkDependencyGraph(fakeGetter, maxDepth - 1, visited)); - } + forEachSubscriber(signal, (sub) => { + const subSig = (sub as unknown as Record)._sig as ReactiveSignal | undefined; + if (subSig && !visited.has(subSig)) { + const subName = (subSig as Record).__name; + const fakeGetter = (() => undefined) as unknown as () => unknown; + const tag = fakeGetter as unknown as Record; + tag.__signal = subSig; + if (subName !== undefined) tag.__name = subName; + downstream.push(walkDependencyGraph(fakeGetter, maxDepth - 1, visited)); } - } + }); return { name: getSignalName(getter), - subscribers: subs ? subs.size : 0, + subscribers: coreGetSubscriberCount(signal), downstream, }; } diff --git a/src/reactivity/track.ts b/src/reactivity/track.ts index 57bc523..52d4502 100644 --- a/src/reactivity/track.ts +++ b/src/reactivity/track.ts @@ -1,67 +1,224 @@ import { devWarn, isDev } from "../core/dev"; import type { ReactiveSignal } from "./signal"; +// --------------------------------------------------------------------------- +// Reactivity core — doubly-linked-list subscription edges. +// +// Each (signal, subscriber) pair is represented by a `SubNode` allocated once +// and spliced into two lists: +// +// signal.subsHead ─▶ node ─▶ node ─▶ ... (via sigNext) +// ↑ +// subscriber.depsHead ─▶ node ─▶ node ─▶ ... (via subNext) +// +// This replaces the prior `Set` on signals plus `Map` on subscribers. Wins: +// +// * O(1) subscribe and O(1) unsubscribe (no hash ops, pointer splice) +// * Cache-friendly pointer traversal in propagate / notify / cleanup +// * One allocation per edge instead of two (was Set entry + Map entry) +// * A node pool eliminates per-edge GC pressure on create/destroy churn +// +// The `__f` single-subscriber cache is no longer needed — a signal with one +// subscriber IS a one-step linked list walk, which already beats the prior +// Set iteration. `__sc` (subscriber count) is maintained for O(1) devtools +// reads. +// --------------------------------------------------------------------------- + type Subscriber = () => void; // Cache dev mode at module load for zero-cost production checks const _isDev = isDev(); -// Stack to support nested subscribers — pre-allocated with index for O(1) push/pop. -// Grows by doubling on overflow; lazily shrinks at end-of-track() when idle so a -// one-off spike in nesting depth doesn't permanently retain the memory. -const STACK_INITIAL = 32; -const STACK_SHRINK_THRESHOLD = 128; // only attempt shrink when capacity exceeds this -const subscriberStack: (Subscriber | null)[] = new Array(STACK_INITIAL); -let stackCapacity = STACK_INITIAL; -let stackTop = -1; -let currentSubscriber: Subscriber | null = null; +// ---------- Subscription edge --------------------------------------------- + +interface SubNode { + // The edge endpoints — null only while the node is sitting in the free pool. + sig: ReactiveSignal | null; + sub: Subscriber | null; + // Epoch stamp refreshed on every recordDependency() call. `retrack()` uses + // this to detect deps that were present before the run but not re-read. + epoch: number; + // Doubly-linked into signal.subsHead (most-recent-first insertion order). + sigPrev: SubNode | null; + sigNext: SubNode | null; + // Doubly-linked into subscriber.depsHead (record order). + subPrev: SubNode | null; + subNext: SubNode | null; + // Saved value of `signal.__activeNode` from when THIS node was activated — + // lets nested tracking runs restore the outer context's active marker when + // they finish, and lets recordDependency refresh existing edges in O(1). + prevActive: SubNode | null; +} -// Subscriber deps stored directly on subscriber as _deps property (avoids WeakMap). -// Signal subscribers stored in Set cached on signal as __s (avoids WeakMap in hot path). +type SignalWithList = ReactiveSignal & { + subsHead?: SubNode | null; + subsTail?: SubNode | null; + __sc?: number; + __name?: string; + // Pointer to the subscription edge whose subscriber is CURRENTLY mid-eval. + // Non-null only during a tracking run. Gives recordDependency O(1) + // "have I already recorded this signal for the current sub?" detection + // without walking the subscriber's dep list. + __activeNode?: SubNode | null; +}; + +// ---------- Node pool ----------------------------------------------------- +// +// High-churn workloads (create/destroy cycles, wide track()+cleanup) allocate +// many edges. Pooling avoids GC pressure by reusing node objects. Cap the +// pool so a pathological spike doesn't retain memory forever. +// +// Shape-stable allocation in `createNode`: every node is born with the same +// hidden class, which matters for V8 inline caches on property reads. +// --------------------------------------------------------------------------- +const POOL_MAX = 4096; +const nodePool: SubNode[] = []; + +function createNode(): SubNode { + return { + sig: null, + sub: null, + epoch: 0, + sigPrev: null, + sigNext: null, + subPrev: null, + subNext: null, + prevActive: null, + }; +} -// Fast notification cache: store the Set reference directly on the signal -// for O(1) property access during notification (avoids WeakMap hash lookup). -// The cached Set is the SAME object stored in signalSubscribers. -const SUBS = "__s" as const; -type SignalWithCache = ReactiveSignal & { [SUBS]?: Set; __f?: Subscriber }; +function allocNode(sig: ReactiveSignal, sub: Subscriber, epoch: number): SubNode { + const n = nodePool.pop(); + if (n) { + n.sig = sig; + n.sub = sub; + n.epoch = epoch; + // prev/next pointers left over from last life are overwritten by link ops. + return n; + } + const fresh = createNode(); + fresh.sig = sig; + fresh.sub = sub; + fresh.epoch = epoch; + return fresh; +} -// --------------------------------------------------------------------------- -// Fast-path (__f / __s) invariant — maintained by syncFastPath() below: -// -// subs.size === 0 → __f = undefined, __s deleted (zero-allocation signal) -// subs.size === 1 → __f = the single subscriber -// subs.size >= 2 → __f = undefined +function freeNode(node: SubNode): void { + node.sig = null; + node.sub = null; + node.sigPrev = null; + node.sigNext = null; + node.subPrev = null; + node.subNext = null; + node.prevActive = null; + if (nodePool.length < POOL_MAX) nodePool.push(node); +} + +// ---------- List splice helpers ------------------------------------------- // -// All add/remove operations must call syncFastPath() after mutating the set -// so no code path leaves these out of sync. Inlined in the hot paths for -// zero-overhead: the function itself exists for correctness & readability. +// Inlined by the JIT in most call sites but factored for correctness — a +// single point of truth for each list's prev/next/head/tail invariant. // --------------------------------------------------------------------------- -function syncFastPath(signal: SignalWithCache, subs: Set): void { - const size = subs.size; - if (size === 0) { - signal.__f = undefined; - delete signal[SUBS]; - } else if (size === 1) { - signal.__f = subs.values().next().value; - } else { - signal.__f = undefined; + +function linkSignal(sig: SignalWithList, node: SubNode): void { + // Insert at the HEAD of signal.subsHead. O(1). + const oldHead = sig.subsHead ?? null; + node.sigPrev = null; + node.sigNext = oldHead; + if (oldHead) oldHead.sigPrev = node; + else sig.subsTail = node; + sig.subsHead = node; + sig.__sc = (sig.__sc ?? 0) + 1; +} + +function unlinkSignal(node: SubNode): void { + const sig = node.sig as SignalWithList | null; + if (!sig) return; + const prev = node.sigPrev; + const next = node.sigNext; + if (prev) prev.sigNext = next; + else sig.subsHead = next; + if (next) next.sigPrev = prev; + else sig.subsTail = prev; + sig.__sc = (sig.__sc ?? 1) - 1; + // If the signal currently holds `node` as its active marker (rare — only + // if we unlink mid-eval, e.g. during pruneStaleDeps), restore to the + // saved prior marker so outer tracking contexts keep working. + if (sig.__activeNode === node) sig.__activeNode = node.prevActive; + // When a signal has no subscribers at all, clear the head/tail slots so + // isolated signals don't pin stale node references through their state + // objects' hidden class slots. + if (sig.__sc === 0) { + sig.subsHead = null; + sig.subsTail = null; } } +function linkSub(sub: SubWithList, node: SubNode): void { + // Append to TAIL of subscriber.depsHead. Appending (vs prepending) keeps + // recordDependency order aligned with dep-read order, which helps any + // future position-based tracking and keeps cleanup traversal predictable. + const oldTail = sub.depsTail ?? null; + node.subPrev = oldTail; + node.subNext = null; + if (oldTail) oldTail.subNext = node; + else sub.depsHead = node; + sub.depsTail = node; +} + +function unlinkSub(node: SubNode): void { + const sub = node.sub as SubWithList | null; + if (!sub) return; + const prev = node.subPrev; + const next = node.subNext; + if (prev) prev.subNext = next; + else sub.depsHead = next; + if (next) next.subPrev = prev; + else sub.depsTail = prev; +} + +// ---------- Module state -------------------------------------------------- + +// `currentSubscriber` is the single source of truth for "who is reading?". +// track() and retrack() save/restore it around the body via a local prev; +// suspendTracking() captures it into `suspendSavedSub` and restores on resume. +// No stack is needed — nested tracking runs each keep their own local prev. +let currentSubscriber: Subscriber | null = null; +// Captured by suspendTracking at entry (when suspendDepth transitions 0→1); +// restored by the matching resumeTracking. Nested suspends just bump depth. +let suspendSavedSub: Subscriber | null = null; + // Notification queue for cascading propagation with deduplication. let notifyDepth = 0; const pendingQueue: Subscriber[] = []; const pendingSet = new Set(); -// Reusable worklist for iterative propagateDirty — avoids recursion on -// wide diamonds where a single signal fans out to many computeds each -// with their own downstream chains. +// Reusable worklist for iterative propagateDirty. const propagateStack: ReactiveSignal[] = []; -/** - * Safely invoke a subscriber, catching errors to prevent one failing - * subscriber from killing remaining subscribers in the notification queue. - */ +// Subscribers carry a `depsHead` / `depsTail` pair plus epoch/cycle fields. +// Kept as a typed alias for readability — at runtime a Subscriber is just +// a plain function, we attach these as untyped props. +type SubWithList = Subscriber & { + depsHead?: SubNode | null; + depsTail?: SubNode | null; + _epoch?: number; + _structDirty?: boolean; + _runEpoch?: number; + _runs?: number; + _c?: number; + _sig?: ReactiveSignal; + __name?: string; + // Cached disposer returned by track() — allocated once on first track(), + // reused for the life of the subscriber. Avoids per-invocation closure + // allocation in hot paths (Wide Graph sink: 10k+ calls, Memory benchmark: + // 25k+ effect creations). + _dispose?: () => void; +}; + +// ---------- Safe invoke --------------------------------------------------- + function safeInvoke(sub: Subscriber): void { try { sub(); @@ -70,303 +227,236 @@ function safeInvoke(sub: Subscriber): void { } } -// Suspend/resume tracking: counter-based for nested computed evaluations. +// ---------- Tracking suspension ------------------------------------------- + let suspendDepth = 0; export let trackingSuspended = false; -// --------------------------------------------------------------------------- -// Subscriber epoch counter for retrack-based stale-dep pruning. -// -// Each call to `retrack()` bumps this counter and stamps the subscriber's -// `_epoch` with the new value. recordDependency() tags each accessed dep -// with the subscriber's current epoch. At end of retrack(), any dep whose -// tagged epoch is not the current one was NOT read during this evaluation -// and is therefore stale — we unsubscribe it and remove the edge. -// -// This closes the stale-dep leak that otherwise accumulates on derived -// getters with conditional branches (e.g. `() => flag() ? a() : b()`): -// without pruning, both `a` and `b` stay subscribed forever even though -// only one is read per evaluation, causing unnecessary re-evaluations. -// --------------------------------------------------------------------------- +export function suspendTracking(): void { + if (suspendDepth === 0) { + // Capture the ACTUAL current subscriber (not null). Resume restores + // to this, so `untracked()` inside a tracking context returns control + // to that context with the right subscriber, without needing a stack. + suspendSavedSub = currentSubscriber; + currentSubscriber = null; + trackingSuspended = true; + } + suspendDepth++; +} + +export function resumeTracking(): void { + suspendDepth--; + if (suspendDepth === 0) { + currentSubscriber = suspendSavedSub; + suspendSavedSub = null; + trackingSuspended = false; + } +} + +export function untracked(fn: () => T): T { + suspendTracking(); + try { + return fn(); + } finally { + resumeTracking(); + } +} + +// ---------- Epoch counter for retrack-based pruning ----------------------- + let subscriberEpochCounter = 0; -/** - * Re-run a subscriber body. Stale deps (present before but not re-read - * during this run) are pruned at end via epoch comparison — fixes the - * conditional-derived over-subscription problem without paying the - * full Set.delete + re-subscribe cost of `track()`'s cleanup phase. - * - * Used by `derived` on every pull. Uses a simple save/restore of - * `currentSubscriber` instead of the stackTop push/pop — measurably - * faster on deep chains where this function runs per-level. - */ +// ---------- retrack ------------------------------------------------------- +// +// Re-run a subscriber body. Stable deps have their epoch stamp refreshed; +// deps that are no longer read are pruned at the end. Used by `derived()` +// to validate / recompute without paying the full Set.delete + re-add cycle +// of `track()`'s cleanup phase. +// --------------------------------------------------------------------------- export function retrack(effectFn: () => void, subscriber: Subscriber): void { const prev = currentSubscriber; currentSubscriber = subscriber; - const sub = subscriber as any; + const sub = subscriber as SubWithList; const epoch = ++subscriberEpochCounter; sub._epoch = epoch; + sub._structDirty = false; + + // Pre-walk: activate every existing dep on its signal so in-body + // recordDependency hits can refresh the existing edge in O(1) via + // `signal.__activeNode === existingNode && existingNode.sub === sub`. + // Each node stashes the prior `__activeNode` value in `prevActive` so + // outer tracking contexts' markers can be restored at post-walk. + for (let n: SubNode | null = sub.depsHead ?? null; n !== null; n = n.subNext) { + const sig = n.sig as SignalWithList; + n.prevActive = sig.__activeNode ?? null; + sig.__activeNode = n; + } + try { effectFn(); } finally { currentSubscriber = prev; - pruneStaleDeps(sub, epoch); - } -} - -/** - * Unsubscribe from any deps recorded with an epoch other than `currentEpoch` - * (i.e. deps that were not re-read during the most recent retrack). - */ -function pruneStaleDeps(sub: any, currentEpoch: number): void { - // Single-dep fast path - if (sub._dep !== undefined) { - if (sub._depEpoch !== currentEpoch) { - const sig = sub._dep as SignalWithCache; - const subs = sig[SUBS]; - if (subs?.delete(sub)) syncFastPath(sig, subs); - sub._dep = undefined; - sub._depEpoch = undefined; - } - return; - } - - // Multi-dep path — _deps is Map - const deps: Map | undefined = sub._deps; - if (!deps || deps.size === 0) return; - - // Collect stales in one pass, mutate in a second — avoids iterating a Map - // while deleting entries on most engines. - let stales: ReactiveSignal[] | undefined; - for (const [signal, epoch] of deps) { - if (epoch !== currentEpoch) { - (stales ??= []).push(signal); + // Combined post-walk + stale-prune. For each node: restore the signal's + // `__activeNode` to whatever outer tracking context had, then drop the + // node if it wasn't refreshed during this run. + let node = sub.depsHead ?? null; + while (node !== null) { + const next: SubNode | null = node.subNext; + const sig = node.sig as SignalWithList; + sig.__activeNode = node.prevActive; + node.prevActive = null; + if (node.epoch !== epoch) { + unlinkSub(node); + unlinkSignal(node); + freeNode(node); + } + node = next; } } - if (!stales) return; - for (const signal of stales) { - deps.delete(signal); - const sig = signal as SignalWithCache; - const subs = sig[SUBS]; - if (subs?.delete(sub)) syncFastPath(sig, subs); - } } -/** - * Track dependencies of an effect or computed subscriber. - * Returns a teardown function to remove all subscriptions. - */ +// ---------- track --------------------------------------------------------- +// +// Full-cleanup + re-run. Used by effects (and one-shot initial setup of +// computeds). Returns a disposer that clears all remaining subs. +// +// Stack-free: saves `currentSubscriber` in a local and restores it in +// `finally`. Nested tracking runs each keep their own local prev; the old +// `subscriberStack` was only ever needed because `suspend/resumeTracking` +// used to push/pop null markers through it. suspend/resume now capture +// the current subscriber directly, so no shared stack is needed. +// --------------------------------------------------------------------------- export function track(effectFn: () => void, subscriber?: Subscriber): () => void { if (!subscriber) subscriber = effectFn; cleanup(subscriber); - ++stackTop; - if (stackTop >= stackCapacity) { - stackCapacity *= 2; - subscriberStack.length = stackCapacity; - } - subscriberStack[stackTop] = subscriber; + const prev = currentSubscriber; currentSubscriber = subscriber; try { effectFn(); } finally { - stackTop--; - currentSubscriber = stackTop >= 0 ? subscriberStack[stackTop] : null; - // Lazy shrink: if the stack is idle and grew well beyond typical usage, - // halve the underlying array so a transient deep-nesting spike doesn't - // retain memory for the process lifetime. One extra branch on the cold - // exit path; no effect on the hot path. - if (stackTop < 0 && stackCapacity > STACK_SHRINK_THRESHOLD) { - stackCapacity = Math.max(STACK_INITIAL, stackCapacity >>> 1); - subscriberStack.length = stackCapacity; - } - } - - return () => cleanup(subscriber); -} + currentSubscriber = prev; -/** - * Suspend dependency tracking. Used by lazy computed re-evaluation. - */ -export function suspendTracking(): void { - if (suspendDepth === 0) { - ++stackTop; - if (stackTop >= stackCapacity) { - stackCapacity *= 2; - subscriberStack.length = stackCapacity; + // Post-walk: restore each signal's `__activeNode` to what outer + // tracking contexts had before this track() started. We never do a + // pre-walk here because cleanup() emptied the dep list up-front. + const sub = subscriber as SubWithList; + for (let n: SubNode | null = sub.depsHead ?? null; n !== null; n = n.subNext) { + const sig = n.sig as SignalWithList; + sig.__activeNode = n.prevActive; + n.prevActive = null; } - subscriberStack[stackTop] = null; - currentSubscriber = null; - trackingSuspended = true; } - suspendDepth++; -} -/** - * Resume dependency tracking after suspendTracking(). - */ -export function resumeTracking(): void { - suspendDepth--; - if (suspendDepth === 0) { - stackTop--; - currentSubscriber = stackTop >= 0 ? subscriberStack[stackTop] : null; - trackingSuspended = false; - } -} - -/** - * Execute a function without tracking any signal reads as dependencies. - * Useful for reading signals inside effects without creating subscriptions. - * - * @param fn Function to execute without dependency tracking - * @returns The return value of fn - */ -export function untracked(fn: () => T): T { - suspendTracking(); - try { - return fn(); - } finally { - resumeTracking(); - } + // Cache the disposer on the subscriber so repeated track() calls (effects + // re-running, derived re-setup) don't each allocate a fresh `() => cleanup` + // closure. For a 10k-subscriber workload this eliminates 10k allocations. + const sub = subscriber as SubWithList; + return sub._dispose ?? (sub._dispose = () => cleanup(subscriber)); } -/** - * Record that the current subscriber depends on this signal. - * - * Fast path: for the first dependency of a subscriber, stores the signal - * directly as _dep (avoiding Map allocation). Promotes to _deps Map only - * when a second dependency is recorded. Most effects/computeds have 1-3 deps, - * so the single-dep fast path eliminates Map overhead in the common case. - * - * Every edge is tagged with the subscriber's current `_epoch` so that - * `retrack()` can identify and prune stale deps at end of evaluation. - * Subscribers that only ever flow through `track()` (effects) don't set - * _epoch; the epoch field is then `undefined` and harmlessly unused. - */ +// ---------- recordDependency ---------------------------------------------- +// +// Called for every signal read inside a tracking context. O(1) in all cases +// via the `signal.__activeNode` back-pointer: +// +// * Pre-walk (retrack) or recordDependency-at-first-read (track) sets +// `signal.__activeNode` to the edge for the current subscriber. +// * Subsequent reads see `__activeNode.sub === currentSubscriber` and +// refresh epoch in place — no linked-list walk. +// +// This is Preact Signals' approach. Without it, a subscriber with N deps +// (e.g. a sink effect in a wide fan-out graph) pays O(N²) per track run. +// --------------------------------------------------------------------------- export function recordDependency(signal: ReactiveSignal) { if (!currentSubscriber) return; - const sub = currentSubscriber as any; - const epoch = sub._epoch; + const sub = currentSubscriber as SubWithList; + const sig = signal as SignalWithList; + const epoch = sub._epoch ?? 0; - // Fast path: check single-dep slot first. Still refresh epoch so - // pruneStaleDeps sees this dep as "live" during retrack. - if (sub._dep === signal) { - sub._depEpoch = epoch; + // O(1) dup check: if the signal's active edge already points at us, + // it's a re-read within this run. Refresh the epoch and we're done. + const active = sig.__activeNode ?? null; + if (active !== null && active.sub === sub) { + active.epoch = epoch; return; } - const deps: Map | undefined = sub._deps; - if (deps) { - // Map.set both adds new edges and refreshes the epoch on existing ones. - // The subs.add() call below is idempotent, so it's safe to run - // unconditionally — Set.add is fast enough that the "already subscribed" - // short-circuit isn't worth the branch. - deps.set(signal, epoch); - } else if (sub._dep !== undefined) { - // Promote single-dep to Map (carry forward the existing epoch). - const map = new Map(); - map.set(sub._dep, sub._depEpoch); - map.set(signal, epoch); - sub._deps = map; - sub._dep = undefined; - sub._depEpoch = undefined; - } else { - // First dep — store directly, no Map allocation - sub._dep = signal; - sub._depEpoch = epoch; - } - - // Register subscriber on the signal. subs.add() is idempotent: if the - // subscriber was already subscribed (stable dep during retrack), size - // won't change and syncFastPath stays a no-op. - const sig = signal as SignalWithCache; - let subs = sig[SUBS]; - if (!subs) { - subs = new Set(); - sig[SUBS] = subs; - } - const prevSize = subs.size; - subs.add(currentSubscriber); - if (subs.size !== prevSize) { - if (subs.size === 1) { - sig.__f = currentSubscriber; - } else if (sig.__f !== undefined) { - sig.__f = undefined; - } - } + // New edge. Stash whatever `__activeNode` was (may be null, may be an + // outer tracking context's node) into `prevActive` so the post-walk + // restores it. + const node = allocNode(signal, sub, epoch); + node.prevActive = active; + sig.__activeNode = node; + linkSub(sub, node); + linkSignal(sig, node); + sub._structDirty = true; } -/** - * Queue all subscribers of a signal for deferred notification. - * Computed subscribers (_c) are propagated through the chain via propagateDirty - * so their downstream effect subscribers get queued correctly. - */ -export function queueSignalNotification(signal: ReactiveSignal): void { - const subs = (signal as SignalWithCache)[SUBS]; - if (!subs) return; - for (const sub of subs) { - if ((sub as any)._c) { - propagateDirty(sub); - } else if (!pendingSet.has(sub)) { - pendingSet.add(sub); - pendingQueue.push(sub); - } +// ---------- cleanup -------------------------------------------------------- +// +// Tear down every edge attached to this subscriber. Called by track() before +// re-running and by the dispose handle. Nodes are returned to the pool. +// +// Exported so callers can dispose a subscriber without track() having to +// allocate a per-call closure `() => cleanup(sub)`. Effect.ts calls this +// directly on dispose, eliminating ~1 closure allocation per track() call. +// --------------------------------------------------------------------------- +export function cleanup(subscriber: Subscriber): void { + const sub = subscriber as SubWithList; + let node = sub.depsHead ?? null; + // We clear the subscriber's head/tail up-front so we don't have to + // repeatedly adjust them while unlinking — each node still needs its own + // signal-side unlink to maintain the signal's list invariant. + sub.depsHead = null; + sub.depsTail = null; + while (node) { + const next = node.subNext; + unlinkSignal(node); + freeNode(node); + node = next; } } -/** - * Cycle detection during notification drain. - * - * We no longer cap the total drain iterations (which conflates large - * legitimate fan-out with real cycles). Instead, we count how many times - * each individual subscriber has fired during the current drain. If any - * single subscriber fires more than `maxSubscriberRepeats` times, that is - * a near-certain sign of a write-reads-self cycle — bail loudly. - * - * Counts live on the subscriber itself (`_runs`, `_runEpoch`), reset lazily - * via an epoch counter to avoid walking all subscribers at end-of-drain. - * - * `maxDrainIterations` is kept as an absolute belt-and-braces safety net; - * it is sized high enough that legitimate apps (100k+ subscribers) never - * hit it, while still preventing a runaway process from eating all memory. - */ +// ---------- Cycle detection ----------------------------------------------- +// +// Per-subscriber repeat count within a single drain. A subscriber that fires +// more than `maxSubscriberRepeats` times in one drain is almost certainly a +// write-reads-self cycle — bail loudly instead of wasting cycles. Counts +// live on the subscriber itself via an epoch to avoid end-of-drain walks. +// --------------------------------------------------------------------------- let maxSubscriberRepeats = 50; let maxDrainIterations = 1_000_000; let drainEpoch = 0; -/** Raise/lower the per-subscriber repeat cap. Returns previous value. */ export function setMaxSubscriberRepeats(n: number): number { const prev = maxSubscriberRepeats; if (Number.isFinite(n) && n > 0) maxSubscriberRepeats = Math.floor(n); return prev; } -/** Raise/lower the absolute drain iteration safety net. Returns previous value. */ export function setMaxDrainIterations(n: number): number { const prev = maxDrainIterations; if (Number.isFinite(n) && n > 0) maxDrainIterations = Math.floor(n); return prev; } -/** - * Record one invocation of `sub` in the current drain and return true iff - * it has just exceeded the per-subscriber repeat cap (indicating a cycle). - */ function tickRepeat(sub: Subscriber): boolean { - const s = sub as any; + const s = sub as SubWithList; if (s._runEpoch !== drainEpoch) { s._runEpoch = drainEpoch; s._runs = 1; return false; } - return ++s._runs > maxSubscriberRepeats; + s._runs = (s._runs ?? 0) + 1; + return s._runs > maxSubscriberRepeats; } function cycleError(sub: Subscriber): void { if (typeof console !== "undefined") { - const name = (sub as any).__name ?? ""; + const name = (sub as SubWithList).__name ?? ""; console.error( `[SibuJS] subscriber "${name}" fired more than ${maxSubscriberRepeats} times — ` + "likely a write-reads-self cycle between effects/signals. Breaking to prevent infinite loop.", @@ -383,18 +473,8 @@ function absoluteDrainError(): void { } } -/** - * Process pending subscriber notifications until the queue is empty. - * - * Convergence model: - * - A subscriber is removed from `pendingSet` immediately before invocation, - * so any cascading write during its execution can re-enqueue it. This - * allows sibling effects to converge on a consistent state when one - * effect writes a signal another effect reads. - * - `tickRepeat` bounds convergence: if a single subscriber fires more - * than `maxSubscriberRepeats` times, we bail — that's a true cycle. - * - `maxDrainIterations` is an absolute safety net for legitimate fan-out. - */ +// ---------- Drain --------------------------------------------------------- + function drainQueue(): void { let i = 0; while (i < pendingQueue.length) { @@ -407,6 +487,9 @@ function drainQueue(): void { cycleError(sub); break; } + // Remove from pendingSet BEFORE invoking so a cascading write during + // this sub's execution can re-enqueue it. Enables sibling-effect + // convergence; tickRepeat caps runaway loops. pendingSet.delete(sub); safeInvoke(sub); } @@ -427,161 +510,114 @@ export function drainNotificationQueue(): void { } } -/** - * Iteratively propagate dirty flags through a computed chain. - * - * Marks each computed dirty and walks downstream subscribers via an explicit - * worklist (no recursion). markDirty (tagged _c) sets the dirty flag; _sig - * exposes the computed's signal for walking downstream. Does NOT eagerly - * evaluate — computedGetter uses track() on re-evaluation to re-register - * dependencies, which is essential for derived-of-derived chains (e.g. - * formula cells referencing other formula cells). - * - * In the __f fast path (single-subscriber chains), sets _d directly on the - * signal — avoids megamorphic function calls to markDirty. Multi-dep - * computeds are marked dirty and pulled lazily to avoid O(n²) re-evaluation - * when many deps update. - */ -function propagateDirty(sub: () => void): void { - sub(); // markDirty: sets dirty flag - const rootSig: ReactiveSignal | undefined = (sub as any)._sig; +// ---------- propagateDirty ------------------------------------------------ +// +// Walks downstream from a changed signal, marking computed subscribers dirty +// and enqueuing effect subscribers. Iterative via a module-level worklist so +// deep chains (1000+ levels) don't consume the JS call stack. +// --------------------------------------------------------------------------- +function propagateDirty(sub: Subscriber): void { + sub(); // markDirty: sets the computed's _d flag + const rootSig: ReactiveSignal | undefined = (sub as SubWithList)._sig; if (!rootSig) return; - // Iterative worklist using a reusable module-level stack. - // Each entry is a signal whose subscribers still need walking. const stack = propagateStack; const baseLen = stack.length; stack.push(rootSig); while (stack.length > baseLen) { - const sig = stack.pop() as ReactiveSignal; - - // Fast path: single subscriber cached in __f - const first: any = (sig as any).__f; - if (first) { - if (first._c) { - const nSig: any = first._sig; - // Skip if already dirty — avoids redundant downstream walks on - // deep chains where the same signal is reached multiple times. - if (!nSig._d) { - nSig._d = true; - stack.push(nSig); + const sig = stack.pop() as SignalWithList; + let node = sig.subsHead ?? null; + while (node) { + const s = node.sub as SubWithList | null; + // node.sub is null only inside freeNode — shouldn't happen mid-walk, + // but the guard keeps us safe against a freed-but-still-linked corner + // case during a throwing effect body. + if (s) { + if (s._c) { + const nSig = s._sig as (SignalWithList & { _d?: boolean }) | undefined; + if (nSig) { + // Avoid redundant downstream walks when the same signal is + // reached by multiple diamond paths — mark dirty inline and + // only push the signal if it wasn't already dirty. + if (!nSig._d) { + nSig._d = true; + stack.push(nSig); + } + } else { + s(); + } + } else if (!pendingSet.has(s)) { + pendingSet.add(s); + pendingQueue.push(s); } - } else if (!pendingSet.has(first)) { - pendingSet.add(first); - pendingQueue.push(first); } - continue; + node = node.sigNext; } + } +} - // Multi-subscriber path (Set iteration) - const subs = (sig as SignalWithCache)[SUBS]; - if (!subs) continue; - - for (const s of subs) { - if ((s as any)._c) { - const nSig: any = (s as any)._sig; - if (nSig && !nSig._d) { - nSig._d = true; // markDirty inline; skip self-call when already dirty - stack.push(nSig); - } else if (!nSig) { - s(); // computed without _sig — fall back to function call - } +// ---------- Public notification entrypoints ------------------------------ + +export function queueSignalNotification(signal: ReactiveSignal): void { + const sig = signal as SignalWithList; + let node = sig.subsHead ?? null; + while (node) { + const s = node.sub as SubWithList | null; + if (s) { + if (s._c) { + propagateDirty(s); } else if (!pendingSet.has(s)) { pendingSet.add(s); pendingQueue.push(s); } } + node = node.sigNext; } } -/** - * Notify all subscribers of a given signal change. - * - * Unified model: - * - For outermost notifications: enqueue all effect subs, propagate dirty - * through computed subs, then drain the queue. A subscriber is - * re-eligible for enqueue once it has begun executing (pendingSet is - * cleared of it before invoke), so sibling effects converge when one - * effect's write dirties a signal another effect reads. - * - Cycles bound: per-subscriber repeat counting (tickRepeat) stops - * runaway write-reads-self loops loudly rather than silently. - * - Single-subscriber fast path: when there is exactly one subscriber, - * inline invocation is safe — there is no sibling that could observe - * an intermediate state — and avoids queue allocation overhead. - * - * This replaces an older 3-pass structure whose fast/slow paths diverged - * on whether effects could re-run during cascade: the fast path allowed it - * (eventually consistent), the slow path did not (single-run-maybe-stale). - * Unification makes both paths eventually consistent. - */ export function notifySubscribers(signal: ReactiveSignal) { - // Fast path: single subscriber (avoids Set iteration entirely) - const first: any = (signal as any).__f; - if (first) { - if (notifyDepth > 0) { - if (first._c) { - propagateDirty(first); - } else if (!pendingSet.has(first)) { - pendingSet.add(first); - pendingQueue.push(first); - } - return; - } - notifyDepth++; - drainEpoch++; - try { - if (first._c) { - propagateDirty(first); - } else if (tickRepeat(first)) { - cycleError(first); - } else { - safeInvoke(first); - } - drainQueue(); - } finally { - notifyDepth--; - if (notifyDepth === 0) { - pendingQueue.length = 0; - pendingSet.clear(); - } - } - return; - } - - const subs = (signal as SignalWithCache)[SUBS]; - if (!subs || subs.size === 0) return; + const sig = signal as SignalWithList; + const head = sig.subsHead; + if (!head) return; if (notifyDepth > 0) { - // Cascading: computed subs propagated iteratively, effects queued with dedup - for (const sub of subs) { - if ((sub as any)._c) { - propagateDirty(sub); - } else if (!pendingSet.has(sub)) { - pendingSet.add(sub); - pendingQueue.push(sub); + // Cascading: enqueue everything with dedup. + let node: SubNode | null = head; + while (node) { + const s = node.sub as SubWithList | null; + if (s) { + if (s._c) { + propagateDirty(s); + } else if (!pendingSet.has(s)) { + pendingSet.add(s); + pendingQueue.push(s); + } } + node = node.sigNext; } return; } - // Outermost multi-subscriber notification. + // Outermost notification: snapshot direct subs into the queue, then drain. + // Using the existing pendingQueue/pendingSet keeps the drain semantics + // (eventual-consistency via pre-invoke pendingSet.delete) identical to the + // Set-based implementation. notifyDepth++; drainEpoch++; try { - // Single iteration over direct subs: - // - computed subs → propagateDirty (marks downstream dirty, queues - // downstream effects via the notifyDepth>0 cascade branch above) - // - effect subs → enqueue with pendingSet dedup - // Iteration order matches Set insertion order, so effects run in - // subscription order during drain (modulo cascaded re-runs). - for (const sub of subs) { - if ((sub as any)._c) { - propagateDirty(sub); - } else if (!pendingSet.has(sub)) { - pendingSet.add(sub); - pendingQueue.push(sub); + let node: SubNode | null = head; + while (node) { + const s = node.sub as SubWithList | null; + if (s) { + if (s._c) { + propagateDirty(s); + } else if (!pendingSet.has(s)) { + pendingSet.add(s); + pendingQueue.push(s); + } } + node = node.sigNext; } drainQueue(); } finally { @@ -593,40 +629,31 @@ export function notifySubscribers(signal: ReactiveSignal) { } } -/** - * Remove a subscriber from all signal dependency lists. - * - * After each removal, syncFastPath() restores the __f / __s invariant: - * empty sets are cleared to release memory, and __f tracks the last - * remaining subscriber when size collapses back to 1. - */ -function cleanup(subscriber: Subscriber) { - const sub = subscriber as any; - - // Fast path: single dependency (no Map to iterate) - const singleDep: ReactiveSignal | undefined = sub._dep; - if (singleDep !== undefined) { - const sig = singleDep as SignalWithCache; - const subs = sig[SUBS]; - if (subs?.delete(subscriber)) { - syncFastPath(sig, subs); - } - sub._dep = undefined; - sub._depEpoch = undefined; - return; - } +// ---------- Devtools helpers ---------------------------------------------- - // Multi-dep path — _deps is Map - const deps: Map | undefined = sub._deps; - if (!deps || deps.size === 0) return; +/** O(1) subscriber count for devtools / introspection. */ +export function getSubscriberCount(signal: ReactiveSignal): number { + return (signal as SignalWithList).__sc ?? 0; +} - for (const signal of deps.keys()) { - const sig = signal as SignalWithCache; - const subs = sig[SUBS]; - if (subs?.delete(subscriber)) { - syncFastPath(sig, subs); - } +/** Return the signals a subscriber currently depends on, in record order. */ +export function getSubscriberDeps(subscriber: Subscriber): ReactiveSignal[] { + const sub = subscriber as SubWithList; + const out: ReactiveSignal[] = []; + let node = sub.depsHead ?? null; + while (node) { + if (node.sig) out.push(node.sig); + node = node.subNext; } + return out; +} - deps.clear(); +/** Iterate subscribers of a signal (devtools graph walk). */ +export function forEachSubscriber(signal: ReactiveSignal, visit: (sub: Subscriber) => void): void { + let node = (signal as SignalWithList).subsHead ?? null; + while (node) { + const s = node.sub; + if (s) visit(s); + node = node.sigNext; + } }