From 95db6cebccc65caa4cacc43c0ad1647a55d7ffef Mon Sep 17 00:00:00 2001 From: hexplus Date: Sat, 28 Mar 2026 15:11:54 -0600 Subject: [PATCH 1/6] Updated CHANGELOG and package.json --- CHANGELOG.md | 16 ++++++++++++++++ package.json | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c767a55..64c424d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,22 @@ This project follows [Semantic Versioning](https://semver.org/). --- +## [1.0.3] — 2026-03-28 + +### Added + +- **Wider `NodeChild` / `NodeChildren` types** — `NodeChild` now accepts `boolean`; `NodeChildren` accepts nested arrays and full reactive functions. Conditional patterns like `condition && element` work without `as any` casts. Boolean values are filtered out in `appendChildren`, `bindChildNode`, `Fragment()`, `htm.ts`, and `resolveChild`. +- **`onCleanup()` lifecycle hook** — `onCleanup(callback, element)` registers teardown logic (closing sockets, clearing timers, removing listeners) tied to an element's disposal. Integrates with the existing `dispose()` system so cleanup runs automatically when `when()`, `match()`, or `each()` swap content. +- **`query()` `select` option** — Optional `select` function that transforms cached data before returning it to consumers. Raw response stays in cache; `select` runs on read, enabling derived views without extra signals. +- **`formatNumber()` and `formatCurrency()`** — `Intl`-based formatting utilities exported from `sibujs/browser`. `formatNumber` wraps `Intl.NumberFormat`; `formatCurrency` is a convenience shorthand that sets `style: "currency"`. + +### Fixed + +- **Boolean values no longer render as text** — `false`, `true` are filtered in all rendering paths (`tagFactory`, `bindChildNode`, `Fragment`, `htm.ts`, `resolveChild`) preventing visible `"false"` text nodes. +- **Lint fixes** — Resolved unused variable in `router.basic.test.ts` and formatting issues flagged by Biome. + +--- + ## [1.0.2] — 2026-03-27 ### Fixed diff --git a/package.json b/package.json index 4a30d20..a3cd741 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sibujs", - "version": "1.0.2", + "version": "1.0.3", "description": "A lightweight, function-based frontend framework that combines the best of React, Svelte, and Vue — with zero VDOM and maximum simplicity. Designed for developers who want fine-grained reactivity and full control without compilation or magic.", "keywords": [ "frontend", From 9487727c338809848170d361ea8775a3fa149ad9 Mon Sep 17 00:00:00 2001 From: hexplus Date: Sat, 28 Mar 2026 22:30:29 -0600 Subject: [PATCH 2/6] ci: use npm install instead of npm ci --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index aab4d99..e156d9e 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -18,7 +18,7 @@ jobs: registry-url: "https://registry.npmjs.org" - name: Install dependencies - run: npm ci + run: npm install - name: Run tests run: npm test From 077718418208d14423f9aeddb63876ce57f6454c Mon Sep 17 00:00:00 2001 From: hexplus Date: Sat, 28 Mar 2026 22:51:26 -0600 Subject: [PATCH 3/6] trusted-publisher --- .github/workflows/publish.yml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index cdf4e5b..f25d1f3 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -4,17 +4,21 @@ on: release: types: [published] +permissions: + id-token: write + contents: read + jobs: publish: runs-on: ubuntu-latest steps: - - name: Checkout código + - name: Checkout code uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: "20" + node-version: "22" registry-url: "https://registry.npmjs.org" - name: Install dependencies @@ -28,5 +32,3 @@ jobs: - name: Publish to npm run: npm publish --access public - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} From ee7cf487a4e8438c2238b7ed54bc652e48b10b6d Mon Sep 17 00:00:00 2001 From: hexplus Date: Sat, 11 Apr 2026 09:51:07 -0600 Subject: [PATCH 4/6] Updated main --- README.md | 54 +++++++++++++++++++++++++++++------------------------- 1 file changed, 29 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 633c67a..61c59bc 100644 --- a/README.md +++ b/README.md @@ -25,15 +25,10 @@ import { div, h1, button, signal, mount } from "sibujs"; function Counter() { const [count, setCount] = signal(0); - return div({ - nodes: [ - h1({ nodes: () => `Count: ${count()}` }), - button({ - nodes: "Increment", - on: { click: () => setCount(c => c + 1) } - }) - ] - }); + return div({ class: "counter" }, [ + h1(() => `Count: ${count()}`), + button({ on: { click: () => setCount(c => c + 1) } }, "Increment"), + ]); } mount(Counter, document.getElementById("app")); @@ -43,32 +38,41 @@ mount(Counter, document.getElementById("app")); SibuJS gives you maximum flexibility with three interoperable styles: -#### 1. Tag Factory (Full Props) -Maximum control with an explicit properties object. Perfect for complex elements. +#### 1. Tag Factory +The canonical form: a props object followed by children as a second +positional argument. No `nodes:` key required at any level of the tree — +children can be a string, a number, a single node, an array, or a +reactive getter. ```javascript -import { div, h1, button } from "sibujs"; - -const [count, setCount] = signal(0); - -return div({ - class: "counter", - nodes: [ - h1({ nodes: () => `Count: ${count()}` }), - button({ nodes: "Increment", on: { click: () => setCount(c => c + 1) } }) - ] -}); +import { div, h1, label, input, button } from "sibujs"; + +return div({ class: "counter" }, [ + h1({ class: "title" }, () => `Count: ${count()}`), + label({ for: "amount" }, "Step"), + input({ id: "amount", type: "number", value: 1 }), + button( + { class: "primary", on: { click: () => setCount(c => c + 1) } }, + "Increment", + ), +]); ``` -#### 2. Shorthand API -Concise and readable for common layouts. Class and children passed as positional arguments. +All legacy forms — `tag({ class, nodes })`, `tag("className", children)`, +`tag("text")`, `tag([children])`, `tag(node)`, `tag(() => reactive)` — +continue to work unchanged. When both `props.nodes` and the positional +second argument are present, the positional wins. + +#### 2. Positional Shorthand +The tersest form. Class and children as positional arguments, for +layouts with no event handlers or custom props. ```javascript import { div, h1, button } from "sibujs"; return div("counter", [ h1(() => `Count: ${count()}`), - button({ nodes: "Increment", on: { click: () => setCount(c => c + 1) } }) + button({ on: { click: () => setCount(c => c + 1) } }, "Increment"), ]); ``` From c7bee73a0db6de50cfbe2b2273b3f2953b451c39 Mon Sep 17 00:00:00 2001 From: hexplus Date: Fri, 17 Apr 2026 19:58:28 -0600 Subject: [PATCH 5/6] fix(reactivity): harden core for convergence, stale-dep pruning, and cycle detection (2.1.0) --- CHANGELOG.md | 36 ++++ index.ts | 2 +- package.json | 5 +- src/core/signals/effect.ts | 70 ++++-- src/devtools/introspect.ts | 13 +- src/reactivity/track.ts | 426 ++++++++++++++++++++++++------------- 6 files changed, 386 insertions(+), 166 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f68fb21..e5f27f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,42 @@ This project follows [Semantic Versioning](https://semver.org/). --- +## [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.** + +### Fixed + +- **Effects that write to a signal they subscribe to no longer silently drop the update.** Previously the re-entrant invocation was dropped with a dev-only warning, leaving the effect's observed state out of sync with reality. Now the update is flagged as `rerunPending` and the effect re-runs after its current body completes, converging on consistent state. A 100-iteration safety cap breaks legitimate write-reads-self cycles with a loud `console.error` instead of hanging. + +- **`derived()` no longer accumulates stale dependencies on conditional code paths.** A getter like `() => flag() ? a() : b()` used to keep both `a` and `b` subscribed forever once both had been read, causing spurious re-evaluations whenever the untaken branch fired. The `retrack()` pull path now tags each dependency with a per-evaluation epoch and unsubscribes any edge whose epoch is stale at end of run — bounded memory, no spurious work. + +- **Sibling effects now converge to consistent state through the outermost notification.** Previously two paths of `notifySubscribers` diverged: the pure-effect fast path allowed re-enqueue (effects could run twice, final state consistent), while the mixed-computed slow path forbade it (effects ran once, possibly observing stale downstream state). Both paths now share a single drain with at-most-once enqueue dedup cleared before invoke — sibling effects that cross-write converge rather than one losing to the other. + +- **Unbounded empty-`__s` allocation per signal.** Signals whose last subscriber disposed kept an empty subscriber `Set` on the signal object for the process lifetime. The set is now cleared when size drops to zero. + +- **`subscriberStack` never released memory after a one-off nesting spike.** A transient deep-nesting excursion (e.g. a debug-mode traversal) could double the stack and retain it forever. The stack now shrinks lazily at end-of-`track()` when idle and over-allocated. + +### Changed + +- **Cycle detection is now per-subscriber repeat-counted instead of total-iteration-capped.** The previous 100 000-iteration cap conflated "infinite cycle" with "legitimate large fan-out" — apps with 100k+ effects in one batch flirted with false positives while real tight cycles could burn the full budget before tripping. The new detector counts per-subscriber firings within a drain and bails when any single subscriber exceeds `maxSubscriberRepeats` (default 50) — accurate, cheap, and tolerant of arbitrary legitimate fan-out. The absolute iteration cap is retained as a safety net at 1 000 000. + +- **`setMaxDrainIterations(n)`** is now the safety-net knob rather than the primary cycle check; semantics unchanged for callers, default raised from 100 000 → 1 000 000. + +### Added + +- **`setMaxSubscriberRepeats(n)`** — raise/lower the per-subscriber repeat cap used for cycle detection. Returns the previous value. + +### Internal + +- Subscriber dep storage in the reactivity core migrated from `Set` to `Map` to carry per-edge epoch tags for `retrack()` pruning. Public API unchanged; the single-dep fast path still avoids `Map` allocation entirely. + +- `__f` / `__s` fast-path invariant centralized in a `syncFastPath()` helper — same performance, simpler to reason about across add/remove sites. + +- Devtools `introspect.getDependencies()` updated for the new `Map` layout; return type unchanged. + +--- + ## [2.0.0] — 2026-04-14 Major hardening + features release. Spans reactivity, rendering, SSR, widgets, security, and build tooling. **2187/2187 tests passing, zero lint errors, zero type errors.** diff --git a/index.ts b/index.ts index 840f700..b3b78e8 100644 --- a/index.ts +++ b/index.ts @@ -74,7 +74,7 @@ export * from "./src/core/ssr-context"; export * from "./src/reactivity/batch"; export * from "./src/reactivity/nextTick"; export * from "./src/reactivity/concurrent"; -export { untracked } from "./src/reactivity/track"; +export { untracked, retrack, setMaxDrainIterations } from "./src/reactivity/track"; export { bindDynamic } from "./src/reactivity/bindAttribute"; // Lazy loading & Suspense diff --git a/package.json b/package.json index 283455c..556ab12 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sibujs", - "version": "2.0.0", + "version": "2.1.0", "description": "A lightweight, function-based frontend framework that combines the best of React, Svelte, and Vue — with zero VDOM and maximum simplicity. Designed for developers who want fine-grained reactivity and full control without compilation or magic.", "keywords": [ "frontend", @@ -122,8 +122,7 @@ } }, "publishConfig": { - "access": "public", - "provenance": true + "access": "public" }, "browserslist": [ "Chrome >= 80", diff --git a/src/core/signals/effect.ts b/src/core/signals/effect.ts index 51967dd..07138da 100644 --- a/src/core/signals/effect.ts +++ b/src/core/signals/effect.ts @@ -116,38 +116,76 @@ export function effect(effectFn: EffectBody | (() => void), options?: EffectOpti 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. We - // can't re-enter without risking infinite recursion, so the update - // is dropped — surface it in dev so the developer can debug. - if (_g.__SIBU_DEV_WARN__ !== false && typeof console !== "undefined") { - console.warn( - "[SibuJS] effect re-entered itself while running — " + - "the triggering update will be ignored. Wrap mutual writes in `batch()` " + - "or split the effect to avoid this.", - ); - } + // 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; return; } running = true; try { - // 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); + 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); } finally { running = false; + rerunPending = false; } }; running = true; try { - cleanupHandle = track(wrappedFn, subscriber); + 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); } finally { running = false; + rerunPending = false; } const hook = _g.__SIBU_DEVTOOLS_GLOBAL_HOOK__; diff --git a/src/devtools/introspect.ts b/src/devtools/introspect.ts index f062ef3..13c3b7e 100644 --- a/src/devtools/introspect.ts +++ b/src/devtools/introspect.ts @@ -53,11 +53,18 @@ export function getSubscriberCount(getter: () => unknown): number { * Get the dependency list of an effect or computed subscriber function. * Returns signal references that the subscriber depends on. * - * Note: This reads the _deps Set that track.ts maintains on subscriber functions. + * 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`). */ export function getDependencies(subscriberFn: () => void): ReactiveSignal[] { - const deps = (subscriberFn as unknown as Record)._deps as Set | undefined; - return deps ? Array.from(deps) : []; + 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); } /** diff --git a/src/reactivity/track.ts b/src/reactivity/track.ts index 346a461..9da80f4 100644 --- a/src/reactivity/track.ts +++ b/src/reactivity/track.ts @@ -6,9 +6,13 @@ 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 -const subscriberStack: (Subscriber | null)[] = new Array(32); -let stackCapacity = 32; +// 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; @@ -19,7 +23,30 @@ let currentSubscriber: Subscriber | null = null; // 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 }; +type SignalWithCache = ReactiveSignal & { [SUBS]?: Set; __f?: Subscriber }; + +// --------------------------------------------------------------------------- +// 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 +// +// 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. +// --------------------------------------------------------------------------- +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; + } +} // Notification queue for cascading propagation with deduplication. let notifyDepth = 0; @@ -47,24 +74,81 @@ function safeInvoke(sub: Subscriber): void { 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. +// --------------------------------------------------------------------------- +let subscriberEpochCounter = 0; + /** - * Re-run a subscriber body WITHOUT cleanup. New deps naturally subscribe via - * recordDependency; previously-subscribed deps stay subscribed (accepting - * mild over-subscription on conditional getters — same model as Vue/MobX/ - * Preact Signals). + * 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. Skips the O(N) Set.delete + Set.add - * cycle per dep that `track()`'s cleanup phase incurs, AND uses a simple - * save/restore of `currentSubscriber` instead of the stackTop push/pop — - * measurably faster on deep chains where this function runs per-level. + * 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. */ export function retrack(effectFn: () => void, subscriber: Subscriber): void { const prev = currentSubscriber; currentSubscriber = subscriber; + const sub = subscriber as any; + const epoch = ++subscriberEpochCounter; + sub._epoch = epoch; 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 && 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); + } + } + if (!stales) return; + for (const signal of stales) { + deps.delete(signal); + const sig = signal as SignalWithCache; + const subs = sig[SUBS]; + if (subs && subs.delete(sub)) syncFastPath(sig, subs); } } @@ -89,6 +173,14 @@ export function track(effectFn: () => void, subscriber?: Subscriber): () => void } 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); @@ -143,45 +235,66 @@ export function untracked(fn: () => T): T { * 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 Set allocation). Promotes to _deps Set only + * 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 Set overhead in the common case. + * 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. */ export function recordDependency(signal: ReactiveSignal) { if (!currentSubscriber) return; const sub = currentSubscriber as any; + const epoch = sub._epoch; - // Fast path: check single-dep slot first - if (sub._dep === signal) return; + // 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; + return; + } - const deps: Set | undefined = sub._deps; + const deps: Map | undefined = sub._deps; if (deps) { - if (deps.has(signal)) return; - deps.add(signal); + // 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 Set - const set = new Set(); - set.add(sub._dep); - set.add(signal); - sub._deps = set; + // 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 Set allocation + // First dep — store directly, no Map allocation sub._dep = signal; + sub._depEpoch = epoch; } - // Register subscriber on the signal - let subs = (signal as SignalWithCache)[SUBS]; + // 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(); - (signal as SignalWithCache)[SUBS] = subs; + sig[SUBS] = subs; } + const prevSize = subs.size; subs.add(currentSubscriber); - if (subs.size === 1) { - (signal as any).__f = currentSubscriber; - } else if ((signal as any).__f !== undefined) { - (signal as any).__f = undefined; + if (subs.size !== prevSize) { + if (subs.size === 1) { + sig.__f = currentSubscriber; + } else if (sig.__f !== undefined) { + sig.__f = undefined; + } } } @@ -204,39 +317,107 @@ export function queueSignalNotification(signal: ReactiveSignal): void { } /** - * Process all pending subscriber notifications. + * 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. * - * The drain cap prevents infinite cycles (effect A writes to signal that - * triggers effect A again forever). Apps with very large legitimate fan-out - * (e.g. >100k effects in a single batch) can raise it via `setMaxDrainIterations`. + * 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. */ -let maxDrainIterations = 100000; +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 per-batch drain iteration cap. Returns previous value. */ +/** 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; + if (s._runEpoch !== drainEpoch) { + s._runEpoch = drainEpoch; + s._runs = 1; + return false; + } + return ++s._runs > maxSubscriberRepeats; +} + +function cycleError(sub: Subscriber): void { + if (typeof console !== "undefined") { + const name = (sub as any).__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.", + ); + } +} + +function absoluteDrainError(): void { + if (typeof console !== "undefined") { + console.error( + `[SibuJS] Notification drain exceeded ${maxDrainIterations} iterations — ` + + "absolute safety net tripped. Breaking to prevent infinite loop.", + ); + } +} + +/** + * 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. + */ +function drainQueue(): void { + let i = 0; + while (i < pendingQueue.length) { + if (i >= maxDrainIterations) { + absoluteDrainError(); + break; + } + const sub = pendingQueue[i++]; + if (tickRepeat(sub)) { + cycleError(sub); + break; + } + pendingSet.delete(sub); + safeInvoke(sub); + } +} + export function drainNotificationQueue(): void { if (notifyDepth > 0) return; notifyDepth++; + drainEpoch++; try { - let i = 0; - while (i < pendingQueue.length) { - if (i >= maxDrainIterations) { - if (typeof console !== "undefined") { - console.error( - `[SibuJS] Notification queue exceeded ${maxDrainIterations} iterations — ` + - "likely an effect that writes to a signal it reads. Breaking to prevent infinite loop.", - ); - } - break; - } - safeInvoke(pendingQueue[i]); - i++; - } + drainQueue(); } finally { notifyDepth--; if (notifyDepth === 0) { @@ -317,15 +498,22 @@ function propagateDirty(sub: () => void): void { /** * Notify all subscribers of a given signal change. * - * Two-pass outermost notification: - * Pass 1: Computed subscribers run first for dirty propagation (iterative). - * Effect subscribers discovered via cascading are queued with dedup. - * Pass 2: Direct effect subscribers run, skipping those already queued - * by cascading (fixes diamond double-execution). - * Pass 3: Drain any remaining cascading effects. + * 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 avoids adding ALL subscribers to pendingSet upfront (which would add - * overhead to the common flat fan-out case with 10K+ effects). + * 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) @@ -341,27 +529,16 @@ export function notifySubscribers(signal: ReactiveSignal) { return; } notifyDepth++; + drainEpoch++; try { if (first._c) { propagateDirty(first); + } else if (tickRepeat(first)) { + cycleError(first); } else { safeInvoke(first); } - // Drain cascading effects - let i = 0; - while (i < pendingQueue.length) { - if (i >= maxDrainIterations) { - if (typeof console !== "undefined") { - console.error( - `[SibuJS] Notification queue exceeded ${maxDrainIterations} iterations — ` + - "likely an effect that writes to a signal it reads. Breaking to prevent infinite loop.", - ); - } - break; - } - safeInvoke(pendingQueue[i]); - i++; - } + drainQueue(); } finally { notifyDepth--; if (notifyDepth === 0) { @@ -388,59 +565,25 @@ export function notifySubscribers(signal: ReactiveSignal) { return; } - // Outermost notification + // Outermost multi-subscriber notification. notifyDepth++; + drainEpoch++; try { - // Snapshot direct subscribers, noting whether any are computed. - // If none are computed, skip Pass 1/2 machinery and invoke directly. - let directCount = 0; - let hasComputedSub = false; + // 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) hasComputedSub = true; - pendingQueue[directCount++] = sub; - } - - if (!hasComputedSub) { - // Fast path: pure effect fan-out — invoke directly, no Pass 2 bookkeeping. - for (let i = 0; i < directCount; i++) { - safeInvoke(pendingQueue[i]); - } - } else { - // Pass 1: Run computed subscribers for dirty propagation (iterative) - for (let i = 0; i < directCount; i++) { - if ((pendingQueue[i] as any)._c) { - propagateDirty(pendingQueue[i]); - } - } - - // Pass 2: Run direct effect subscribers, skip those already queued - // by cascading during Pass 1 (prevents diamond double-execution). - // Add sub to pendingSet BEFORE invoking so any re-entrant cascade - // cannot double-execute the same effect. - for (let i = 0; i < directCount; i++) { - const sub = pendingQueue[i]; - if (!(sub as any)._c && !pendingSet.has(sub)) { - pendingSet.add(sub); - safeInvoke(sub); - } - } - } - - // Pass 3: Drain cascading effects queued during propagation - let i = directCount; - while (i < pendingQueue.length) { - if (i - directCount >= maxDrainIterations) { - if (typeof console !== "undefined") { - console.error( - `[SibuJS] Notification queue exceeded ${maxDrainIterations} iterations — ` + - "likely an effect that writes to a signal it reads. Breaking to prevent infinite loop.", - ); - } - break; + if ((sub as any)._c) { + propagateDirty(sub); + } else if (!pendingSet.has(sub)) { + pendingSet.add(sub); + pendingQueue.push(sub); } - safeInvoke(pendingQueue[i]); - i++; } + drainQueue(); } finally { notifyDepth--; if (notifyDepth === 0) { @@ -452,39 +595,36 @@ 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 Set to iterate) + // Fast path: single dependency (no Map to iterate) const singleDep: ReactiveSignal | undefined = sub._dep; if (singleDep !== undefined) { - const subs = (singleDep as SignalWithCache)[SUBS]; - if (subs) { - subs.delete(subscriber); - if ((singleDep as any).__f === subscriber) { - (singleDep as any).__f = subs.size === 1 ? subs.values().next().value : undefined; - } else if (subs.size === 1 && (singleDep as any).__f === undefined) { - (singleDep as any).__f = subs.values().next().value; - } + const sig = singleDep as SignalWithCache; + const subs = sig[SUBS]; + if (subs && subs.delete(subscriber)) { + syncFastPath(sig, subs); } sub._dep = undefined; + sub._depEpoch = undefined; return; } - // Multi-dep path - const deps: Set | undefined = sub._deps; + // Multi-dep path — _deps is Map + const deps: Map | undefined = sub._deps; if (!deps || deps.size === 0) return; - for (const signal of deps) { - const subs = (signal as SignalWithCache)[SUBS]; - if (subs) { - subs.delete(subscriber); - if ((signal as any).__f === subscriber) { - (signal as any).__f = subs.size === 1 ? subs.values().next().value : undefined; - } else if (subs.size === 1 && (signal as any).__f === undefined) { - (signal as any).__f = subs.values().next().value; - } + for (const signal of deps.keys()) { + const sig = signal as SignalWithCache; + const subs = sig[SUBS]; + if (subs && subs.delete(subscriber)) { + syncFastPath(sig, subs); } } From fdb6b40c4be102e82e65bea4dd24dff52dc825df Mon Sep 17 00:00:00 2001 From: hexplus Date: Fri, 17 Apr 2026 20:02:54 -0600 Subject: [PATCH 6/6] Linter fixes --- src/reactivity/track.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/reactivity/track.ts b/src/reactivity/track.ts index 9da80f4..57bc523 100644 --- a/src/reactivity/track.ts +++ b/src/reactivity/track.ts @@ -124,7 +124,7 @@ function pruneStaleDeps(sub: any, currentEpoch: number): void { if (sub._depEpoch !== currentEpoch) { const sig = sub._dep as SignalWithCache; const subs = sig[SUBS]; - if (subs && subs.delete(sub)) syncFastPath(sig, subs); + if (subs?.delete(sub)) syncFastPath(sig, subs); sub._dep = undefined; sub._depEpoch = undefined; } @@ -148,7 +148,7 @@ function pruneStaleDeps(sub: any, currentEpoch: number): void { deps.delete(signal); const sig = signal as SignalWithCache; const subs = sig[SUBS]; - if (subs && subs.delete(sub)) syncFastPath(sig, subs); + if (subs?.delete(sub)) syncFastPath(sig, subs); } } @@ -608,7 +608,7 @@ function cleanup(subscriber: Subscriber) { if (singleDep !== undefined) { const sig = singleDep as SignalWithCache; const subs = sig[SUBS]; - if (subs && subs.delete(subscriber)) { + if (subs?.delete(subscriber)) { syncFastPath(sig, subs); } sub._dep = undefined; @@ -623,7 +623,7 @@ function cleanup(subscriber: Subscriber) { for (const signal of deps.keys()) { const sig = signal as SignalWithCache; const subs = sig[SUBS]; - if (subs && subs.delete(subscriber)) { + if (subs?.delete(subscriber)) { syncFastPath(sig, subs); } }