From 2ca974ddadf474a5034a76feff814d6a7526a15a Mon Sep 17 00:00:00 2001 From: Andrii Volynets Date: Sat, 28 Mar 2026 16:25:44 +0200 Subject: [PATCH] Refactor ownership cleanup and content slot management - Updated the `addCleanup` function to handle cleanup functions more efficiently by using a unified `Cleanup` type. - Improved error handling in cleanup invocation with a dedicated `invokeCleanup` function. - Simplified the `runCleanups` function to streamline cleanup execution and error reporting. - Refactored the `createContentSlot` function to enhance clarity and reduce redundancy in state management. - Introduced unmounting logic for current content in `unmountCurrent` to ensure proper cleanup of mounted nodes. - Added tests for ownership lifecycle to verify the correct behavior of cleanup and disposal processes, including error handling during cleanup. - Implemented a new runtime scheduler interface to manage flushing of reactive work and commit processes. - Added performance benchmarks for various reactive scenarios to assess efficiency and responsiveness. --- .../runtime/rollup.walkers-jit.config.ts | 26 + .../runtime/src/reactivity/engine/tracking.ts | 55 +- .../src/reactivity/shape/ReactiveMeta.ts | 2 +- .../src/reactivity/shape/methods/connect.ts | 17 +- .../src/reactivity/walkers/propagate.ts | 305 +++----- .../src/reactivity/walkers/shouldRecompute.ts | 285 ++++---- .../runtime/tests/{ => mjs}/runtime.e2e.mjs | 0 .../@reflex/runtime/tests/walkers.jit.mjs | 678 ++++++++++++++++++ .../src/ownership/ownership.cleanup.ts | 51 +- .../reflex-dom/src/structure/content-slot.ts | 182 ++--- packages/reflex-dom/test/ownership.test.ts | 91 +++ packages/reflex/src/infra/runtime.ts | 5 +- .../reflex/src/policy/effect_scheduler.ts | 2 +- .../reflex/src/policy/runtimeScheduler.ts | 56 ++ 14 files changed, 1262 insertions(+), 493 deletions(-) create mode 100644 packages/@reflex/runtime/rollup.walkers-jit.config.ts rename packages/@reflex/runtime/tests/{ => mjs}/runtime.e2e.mjs (100%) create mode 100644 packages/@reflex/runtime/tests/walkers.jit.mjs create mode 100644 packages/reflex/src/policy/runtimeScheduler.ts diff --git a/packages/@reflex/runtime/rollup.walkers-jit.config.ts b/packages/@reflex/runtime/rollup.walkers-jit.config.ts new file mode 100644 index 0000000..cc264b6 --- /dev/null +++ b/packages/@reflex/runtime/rollup.walkers-jit.config.ts @@ -0,0 +1,26 @@ +import replace from "@rollup/plugin-replace"; +import resolve from "@rollup/plugin-node-resolve"; + +export default { + input: "tests/walkers.jit.mjs", + output: { + file: "dist/walkers.jit.js", + format: "esm", + sourcemap: false, + }, + treeshake: { + moduleSideEffects: false, + propertyReadSideEffects: false, + }, + plugins: [ + resolve({ + extensions: [".js", ".mjs"], + }), + replace({ + preventAssignment: true, + values: { + __DEV__: "false", + }, + }), + ], +}; diff --git a/packages/@reflex/runtime/src/reactivity/engine/tracking.ts b/packages/@reflex/runtime/src/reactivity/engine/tracking.ts index 7030746..720714b 100644 --- a/packages/@reflex/runtime/src/reactivity/engine/tracking.ts +++ b/packages/@reflex/runtime/src/reactivity/engine/tracking.ts @@ -3,7 +3,7 @@ import type ReactiveNode from "../shape/ReactiveNode"; import type { ReactiveEdge } from "../shape/ReactiveEdge"; import { reuseOrCreateIncomingEdge, - unlinkEdge, + unlinkDetachedIncomingEdgeSequence, } from "../shape/methods/connect"; /** @@ -17,15 +17,36 @@ export function trackRead(source: ReactiveNode): void { if (!consumer) return; const prevEdge = consumer.depsTail; - const nextExpected = prevEdge !== null ? prevEdge.nextIn : consumer.firstIn; - const edge = reuseOrCreateIncomingEdge( + if (prevEdge !== null) { + if (prevEdge.from === source) return; + + const nextExpected = prevEdge.nextIn; + if (nextExpected !== null && nextExpected.from === source) { + consumer.depsTail = nextExpected; + return; + } + + consumer.depsTail = reuseOrCreateIncomingEdge( + source, + consumer, + prevEdge, + nextExpected, + ); + return; + } + + const firstIn = consumer.firstIn; + if (firstIn !== null && firstIn.from === source) { + consumer.depsTail = firstIn; + return; + } + + consumer.depsTail = reuseOrCreateIncomingEdge( source, consumer, - prevEdge, - nextExpected, + null, + firstIn, ); - - consumer.depsTail = edge; } /** @@ -34,11 +55,21 @@ export function trackRead(source: ReactiveNode): void { */ export function cleanupStaleSources(node: ReactiveNode): void { const tail = node.depsTail; - let edge: ReactiveEdge | null = tail !== null ? tail.nextIn : node.firstIn; + let staleHead: ReactiveEdge | null; + + if (tail !== null) { + staleHead = tail.nextIn; + if (staleHead === null) return; - while (edge) { - const next: ReactiveEdge | null = edge.nextIn; - unlinkEdge(edge); - edge = next; + tail.nextIn = null; + node.lastIn = tail; + } else { + staleHead = node.firstIn; + if (staleHead === null) return; + + node.firstIn = null; + node.lastIn = null; } + + unlinkDetachedIncomingEdgeSequence(staleHead); } diff --git a/packages/@reflex/runtime/src/reactivity/shape/ReactiveMeta.ts b/packages/@reflex/runtime/src/reactivity/shape/ReactiveMeta.ts index 68a58b0..03d9b99 100644 --- a/packages/@reflex/runtime/src/reactivity/shape/ReactiveMeta.ts +++ b/packages/@reflex/runtime/src/reactivity/shape/ReactiveMeta.ts @@ -1,4 +1,4 @@ -import ReactiveNode from "./ReactiveNode"; +import type ReactiveNode from "./ReactiveNode"; export enum ReactiveNodeState { Producer = 1 << 0, diff --git a/packages/@reflex/runtime/src/reactivity/shape/methods/connect.ts b/packages/@reflex/runtime/src/reactivity/shape/methods/connect.ts index ec51620..44d23af 100644 --- a/packages/@reflex/runtime/src/reactivity/shape/methods/connect.ts +++ b/packages/@reflex/runtime/src/reactivity/shape/methods/connect.ts @@ -4,7 +4,7 @@ import { type ReactiveEdge, } from "../ReactiveEdge"; import { isDisposedNode, ReactiveNodeState } from "../ReactiveMeta"; -import ReactiveNode from "../ReactiveNode"; +import type ReactiveNode from "../ReactiveNode"; // ─── Internal helpers ──────────────────────────────────────────────────────── @@ -87,10 +87,6 @@ export function reuseOrCreateIncomingEdge( prev: ReactiveEdge | null, nextExpected: ReactiveEdge | null, ): ReactiveEdge { - // Fast path: the edge we already hold is the right one. - if (prev?.from === from) return prev; - if (nextExpected?.from === from) return nextExpected; - // Scan the rest of the incoming list for a reusable edge. for ( let edge = nextExpected ? nextExpected.nextIn : to.firstIn; @@ -111,6 +107,17 @@ export function reuseOrCreateIncomingEdge( return linkEdge(from, to, prev); } +export function unlinkDetachedIncomingEdgeSequence( + edge: ReactiveEdge | null, +): void { + while (edge) { + const next = edge.nextIn; + detachOutEdge(edge.from, edge); + clearReactiveEdgeLinks(edge); + edge = next; + } +} + /** * Full incoming-edge sweep used by disposal paths. * Cold-path traversal that tears down every source connection. diff --git a/packages/@reflex/runtime/src/reactivity/walkers/propagate.ts b/packages/@reflex/runtime/src/reactivity/walkers/propagate.ts index af82034..3954bd2 100644 --- a/packages/@reflex/runtime/src/reactivity/walkers/propagate.ts +++ b/packages/@reflex/runtime/src/reactivity/walkers/propagate.ts @@ -7,25 +7,9 @@ import { ReactiveNodeState, } from "../shape"; -// Returns true if `edge` belongs to the confirmed "tracked prefix" of the -// consumer's incoming dependency list. -// -// Background: while a consumer is re-executing (Tracking=true), it rebuilds -// its dependency list left-to-right. depsTail is a cursor that marks how far -// the new prefix has been confirmed so far: -// -// firstIn → [e1] → [e2] → depsTail → [e3] → [e4] → null -// tracked prefix ↑ stale suffix ↑ -// -// Only edges in the tracked prefix may invalidate the consumer mid-execution. -// Invalidating a stale-suffix edge would mark the consumer dirty based on a -// dependency it may no longer even read — a false positive. -// -// Example — depsTail = e2, checking e1 (prefix): -// Walk e1.prevIn → null without hitting depsTail → e1 is before depsTail → true. -// -// Example — depsTail = e2, checking e3 (stale suffix): -// Walk e3.prevIn → e2 === depsTail → e3 is after depsTail → false. +const INVALIDATION_SLOW_PATH_MASK = + DIRTY_STATE | ReactiveNodeState.Disposed | WALKER_STATE; + function isTrackedPrefixEdge( edge: ReactiveEdge, depsTail: ReactiveEdge | null, @@ -40,17 +24,7 @@ function isTrackedPrefixEdge( return true; } -// Dispatch a watcher's invalidation callback, collecting any thrown error -// without letting it interrupt the remaining watcher notifications. -// -// `thrown` is the first error seen so far (null = none yet). -// Returns the error to re-throw after all watchers have been notified. -// -// Example — two watchers, second one throws: -// dispatchWatcherInvalidation(w1, null) → null (w1 ok) -// dispatchWatcherInvalidation(w2, null) → Error (w2 threw, captured) -// after loop: throw Error -function dispatchWatcherInvalidation( +function notifyWatcherInvalidation( node: ReactiveNode, thrown: unknown, ): unknown { @@ -63,15 +37,7 @@ function dispatchWatcherInvalidation( return thrown; } -// Promote a subscriber from Invalid → Changed if and only if it is currently -// in the Invalid state (and no other dirty bit is set). -// -// Used by propagateOnce to eagerly upgrade direct subscribers of a confirmed -// changed producer before they are pulled. Only Invalid nodes are candidates: -// nodes already Changed, Disposed, or in other dirty states are left alone. -// -// Returns true when the promotion happened (caller should notify watchers). -function promoteChangedSubscriber(node: ReactiveNode): boolean { +function promoteInvalidSubscriber(node: ReactiveNode): boolean { const state = node.state; if ((state & DIRTY_STATE) !== ReactiveNodeState.Invalid) return false; @@ -80,71 +46,16 @@ function promoteChangedSubscriber(node: ReactiveNode): boolean { return true; } -// Compute the exact state bitmask that should be written to `edge.to` (the -// subscriber) when its upstream dependency is being invalidated. -// -// Returns 0 to mean "do not touch this subscriber" — either it is already -// dirty, disposed, or the invalidation is not permitted under the current -// tracking rules. -// -// The decision tree (in order): -// -// 1. Already dirty or disposed → skip (0). -// Touching an already-dirty node wastes work; Disposed nodes must never -// be re-activated. -// -// 2. promoteImmediate=true AND subscriber is not actively tracking: -// Write Changed (not just Invalid) so pull-side skips the dep walk. -// Clear Visited first if the node is in a walker state (stale marker). -// -// Example: a producer's direct subscriber in an idle effect: -// state = Idle → nextState = Idle | Changed -// -// 3. subscriber is not in any walker state (clean idle node): -// Fast path — just add Invalid. -// -// Example: computed C is idle, dep A changed: -// state = 0 → nextState = Invalid -// -// 4. subscriber is in a walker state but not actively tracking: -// Clear stale Visited bits, then add Invalid. -// -// Example: computed C was visited in a previous DFS pass (Visited set), -// but is not currently re-executing: -// state = Visited → nextState = (Visited cleared) | Invalid -// -// 5. subscriber IS actively tracking (Tracking=true): -// Only allowed if `edge` is within the confirmed tracked prefix (see -// isTrackedPrefixEdge). If so, set both Visited and Invalid — Visited -// records that we hit an active dep during this push walk. -// -// Example: computed C is mid-execution, depsTail=e2, edge=e1 (prefix): -// state = Tracking | Visited? → nextState = state | Visited | Invalid -// Example: edge=e3 (stale suffix) → return 0, do not invalidate. -function getInvalidatedSubscriberState( +function getSlowInvalidatedSubscriberState( edge: ReactiveEdge, + state: number, promoteImmediate: boolean, ): number { const sub = edge.to; - const state = sub.state; - - if ((state & (DIRTY_STATE | ReactiveNodeState.Disposed)) !== 0) { - return 0; - } - const isTracking = (state & ReactiveNodeState.Tracking) !== 0; - const inWalker = (state & WALKER_STATE) !== 0; - - if (!inWalker) { - return ( - state | - (promoteImmediate && !isTracking - ? ReactiveNodeState.Changed - : ReactiveNodeState.Invalid) - ); - } + if ((state & (DIRTY_STATE | ReactiveNodeState.Disposed)) !== 0) return 0; - if (!isTracking) { + if ((state & ReactiveNodeState.Tracking) === 0) { const cleared = state & ~ReactiveNodeState.Visited; return ( cleared | @@ -152,151 +63,131 @@ function getInvalidatedSubscriberState( ); } - if (!isTrackedPrefixEdge(edge, sub.depsTail)) { - return 0; - } - - return state | ReactiveNodeState.Visited | ReactiveNodeState.Invalid; + return isTrackedPrefixEdge(edge, sub.depsTail) + ? state | ReactiveNodeState.Visited | ReactiveNodeState.Invalid + : 0; } -// Shallow one-level push: promote all direct Invalid subscribers of `node` -// to Changed, and notify any watcher subscribers. -// -// Called when a computed node is confirmed changed and has multiple -// subscribers (fanout). This eagerly upgrades siblings so their pull-side -// shouldRecompute can skip re-examining `node`'s subtree. -// -// Only promotes nodes that are exactly Invalid (DIRTY_STATE === Invalid). -// Nodes already Changed or in other dirty states are left alone. -// -// Example — computed C has subscribers D (Invalid) and E (Changed): -// D: promoteChangedSubscriber → true → D.state = Changed (+ watcher notify if needed) -// E: promoteChangedSubscriber → false → skipped -// -// Does NOT recurse into subscribers' own outgoing edges — that is propagate's job. export function propagateOnce(node: ReactiveNode): void { let thrown: unknown = null; for (let edge = node.firstOut; edge !== null; edge = edge.nextOut) { const sub = edge.to; - if (!promoteChangedSubscriber(sub)) continue; + if (!promoteInvalidSubscriber(sub)) continue; if ((sub.state & ReactiveNodeState.Watcher) !== 0) { - thrown = dispatchWatcherInvalidation(sub, thrown); + thrown = notifyWatcherInvalidation(sub, thrown); } } if (thrown !== null) throw thrown; } -/** - * Push-side non-recursive DFS over outgoing subscriber edges. - * It starts in the cheapest mode possible: - * mark one subscriber, keep walking a single chain if there is only one edge, - * and escalate to sibling-resume DFS only when branching actually appears. - * - * Called when a signal or computed node changes value and must notify the - * full downstream subscriber tree. - * - * Graph traversal mechanics: - * - * `edge` — the edge currently being processed - * `resume` — next sibling edge to process after the current subtree - * `stack` — saved (resume, promote) pairs for ancestor sibling chains - * `promote` — whether to write Changed (not just Invalid) to the current sub - * `resumePromote` — promote value to restore when we pop back to a saved frame - * - * Example graph (A changed, B/C/D are subscribers): - * - * A ──► B ──► D - * A ──► C - * - * startEdge = A→B, resume = A→C - * - * Iteration 1: edge=A→B → mark B Invalid, B has child D - * push {edge: A→C, promote} onto stack - * edge=B→D, resume=null - * - * Iteration 2: edge=B→D → mark D Invalid, D has no children - * resume=null, pop stack → edge=A→C, resume=null - * - * Iteration 3: edge=A→C → mark C Invalid, C has no children - * resume=null, stack empty → break - * - * promoteImmediate=true is passed when the source is a confirmed-changed - * producer and its direct subscribers should be upgraded to Changed immediately - * (skipping the pull-side dep walk for them). - * - * Example — effect E depends on signal S (promoteImmediate=true): - * S.set(v) → propagate(S→E, promoteImmediate=true) - * getInvalidatedSubscriberState sees promoteImmediate && !Tracking - * → writes Changed to E → E's scheduler fires without a shouldRecompute call. - * - * Watcher nodes are leaves: they receive a notification callback but are - * never descended into (they have no meaningful firstOut). - * - * Error handling: watcher callbacks may throw. All watchers are notified - * before the first error is re-thrown (same pattern as propagateOnce). - */ -export function propagate( - startEdge: ReactiveEdge, - promoteImmediate = false, -): void { - const stack: Array<{ edge: ReactiveEdge; promote: boolean }> = []; +function propagateBranching( + edge: ReactiveEdge, + promote: boolean, + resume: ReactiveEdge | null, + resumePromote: boolean, + thrown: unknown, +): unknown { + const edgeStack: ReactiveEdge[] = []; + const promoteStack: boolean[] = []; let stackTop = -1; - let edge = startEdge; - let resume: ReactiveEdge | null = startEdge.nextOut; - let promote = promoteImmediate; - let resumePromote = promoteImmediate; - let thrown: unknown = null; + // The fast invalidation branch stays duplicated here and in propagateLinear. + // That keeps the hot loop flatter and benchmarks better than routing through + // a shared helper before entering the slow path. while (true) { const sub = edge.to; - const nextState = getInvalidatedSubscriberState(edge, promote); + const state = sub.state; + const nextState = + (state & INVALIDATION_SLOW_PATH_MASK) === 0 + ? state | + (promote ? ReactiveNodeState.Changed : ReactiveNodeState.Invalid) + : getSlowInvalidatedSubscriberState(edge, state, promote); if (nextState !== 0) { sub.state = nextState; if ((nextState & ReactiveNodeState.Watcher) !== 0) { - // Watchers are terminal — notify but do not descend. - thrown = dispatchWatcherInvalidation(sub, thrown); - } else if (sub.firstOut !== null) { - // sub has its own subscribers: descend, saving the current sibling - // chain so we can resume it after the subtree is fully walked. - if (resume !== null) - stack[++stackTop] = { edge: resume, promote: resumePromote }; - - edge = sub.firstOut; - resume = edge.nextOut; - // Children of sub are never directly promoted — only the root caller - // decides promoteImmediate for the starting level. - promote = resumePromote = false; - continue; + thrown = notifyWatcherInvalidation(sub, thrown); + } else { + const firstOut = sub.firstOut; + if (firstOut !== null) { + if (resume !== null) { + stackTop += 1; + edgeStack[stackTop] = resume; + promoteStack[stackTop] = resumePromote; + } + + edge = firstOut; + resume = edge.nextOut; + promote = resumePromote = false; + continue; + } } - // else: sub has no outgoing edges — it is a leaf, fall through to advance. } - // nextState === 0: subscriber skipped (already dirty / disposed / stale suffix). - // ── Advance to next edge ────────────────────────────────────────────────── - // - // Priority: resume sibling in current chain → pop saved frame from stack. - // - // Example (after processing B→D above): - // resume = null (D had no siblings) → pop stack → frame = {edge: A→C} - // edge = A→C, resume = A→C.nextOut (null here) if (resume !== null) { edge = resume; promote = resumePromote; resume = edge.nextOut; } else if (stackTop >= 0) { - const frame = stack[stackTop--]!; - edge = frame.edge; - promote = resumePromote = frame.promote; + edge = edgeStack[stackTop]!; + promote = resumePromote = promoteStack[stackTop]!; + stackTop -= 1; resume = edge.nextOut; } else { - break; + return thrown; + } + } +} + +function propagateLinear( + edge: ReactiveEdge, + promote: boolean, + thrown: unknown, +): unknown { + while (true) { + const sub = edge.to; + const state = sub.state; + const nextState = + (state & INVALIDATION_SLOW_PATH_MASK) === 0 + ? state | + (promote ? ReactiveNodeState.Changed : ReactiveNodeState.Invalid) + : getSlowInvalidatedSubscriberState(edge, state, promote); + const next = edge.nextOut; + + if (nextState !== 0) { + sub.state = nextState; + + if ((nextState & ReactiveNodeState.Watcher) !== 0) { + thrown = notifyWatcherInvalidation(sub, thrown); + } else { + const firstOut = sub.firstOut; + if (firstOut !== null) { + edge = firstOut; + + if (next !== null) { + return propagateBranching(edge, false, next, promote, thrown); + } + + promote = false; + continue; + } + } } + + if (next === null) return thrown; + edge = next; } +} + +export function propagate( + startEdge: ReactiveEdge, + promoteImmediate = false, +): void { + const thrown = propagateLinear(startEdge, promoteImmediate, null); if (thrown !== null) throw thrown; } diff --git a/packages/@reflex/runtime/src/reactivity/walkers/shouldRecompute.ts b/packages/@reflex/runtime/src/reactivity/walkers/shouldRecompute.ts index 17d7a53..d57d1ab 100644 --- a/packages/@reflex/runtime/src/reactivity/walkers/shouldRecompute.ts +++ b/packages/@reflex/runtime/src/reactivity/walkers/shouldRecompute.ts @@ -1,10 +1,6 @@ import { recompute } from "../engine/compute"; import type { ReactiveNode } from "../shape"; -import { - DIRTY_STATE, - type ReactiveEdge, - ReactiveNodeState, -} from "../shape"; +import { DIRTY_STATE, type ReactiveEdge, ReactiveNodeState } from "../shape"; import { propagateOnce } from "./propagate"; // Fanout matters only when the dependency has multiple subscribers. In that @@ -40,17 +36,172 @@ function hasFanout(link: ReactiveEdge): boolean { // Only A changed → recompute(C) runs → new value differs → changed=true. // If D and E both depend on C → hasFanout=true → propagateOnce(C) // marks D and E invalid immediately (push side) so they don't pull stale. -function refreshDependency(link: ReactiveEdge, node: ReactiveNode): boolean { - let changed; +function refreshDependencyNoFanout(node: ReactiveNode, state: number): boolean { + if ((state & ReactiveNodeState.Producer) !== 0) { + node.state = state & ~DIRTY_STATE; + return (state & ReactiveNodeState.Changed) !== 0; + } + + return recompute(node); +} + +function refreshDependency( + link: ReactiveEdge, + node: ReactiveNode, + state = node.state, +): boolean { + const changed = refreshDependencyNoFanout(node, state); + if (changed && hasFanout(link)) propagateOnce(node); + return changed; +} + +function clearInvalid(node: ReactiveNode): void { + node.state &= ~ReactiveNodeState.Invalid; +} + +function shouldRecomputeBranching( + link: ReactiveEdge, + consumer: ReactiveNode, + stack: ReactiveEdge[], + stackTop: number, +): boolean { + let changed = false; + + // Stack entries remember which parent edge should be refreshed after the + // current dependency subtree finishes resolving. + outer: while (true) { + const dep = link.from; + const depState = dep.state; + + if ((consumer.state & ReactiveNodeState.Changed) !== 0) { + changed = true; + } else if ((depState & ReactiveNodeState.Changed) !== 0) { + changed = refreshDependency(link, dep, depState); + } else if ( + (depState & ReactiveNodeState.Producer) === 0 && + (depState & DIRTY_STATE) !== 0 + ) { + const deps = dep.firstIn; + if (deps !== null) { + stackTop += 1; + stack[stackTop] = link; + link = deps; + consumer = dep; + continue; + } + + changed = refreshDependency(link, dep, depState); + } + + if (!changed) { + const next = link.nextIn; + if (next !== null) { + link = next; + continue; + } + + clearInvalid(consumer); + } + + while (stackTop >= 0) { + const parentLink = stack[stackTop]!; + stackTop -= 1; + + if (changed) { + changed = refreshDependency(parentLink, consumer); + } else { + clearInvalid(consumer); + } + + consumer = parentLink.to; + + if (!changed) { + const next = parentLink.nextIn; + if (next !== null) { + link = next; + continue outer; + } + } + } - if ((node.state & ReactiveNodeState.Producer) !== 0) { - changed = (node.state & ReactiveNodeState.Changed) !== 0; - node.state = node.state & ~DIRTY_STATE; return changed; } +} - changed = recompute(node); - if (changed && hasFanout(link)) propagateOnce(node); +function shouldRecomputeLinear( + node: ReactiveNode, + firstIn: ReactiveEdge, +): boolean { + const stack: ReactiveEdge[] = []; + let stackTop = -1; + let link = firstIn; + let consumer = node; + let changed = false; + + while (true) { + if (link.nextIn !== null) { + return shouldRecomputeBranching(link, consumer, stack, stackTop); + } + + if ((consumer.state & ReactiveNodeState.Changed) !== 0) { + changed = true; + break; + } + + const dep = link.from; + const depState = dep.state; + + if ((depState & ReactiveNodeState.Changed) !== 0) { + changed = refreshDependency(link, dep, depState); + break; + } + + if ( + (depState & ReactiveNodeState.Producer) === 0 && + (depState & DIRTY_STATE) !== 0 + ) { + const deps = dep.firstIn; + if (deps !== null) { + if (deps.nextIn !== null) { + stackTop += 1; + stack[stackTop] = link; + return shouldRecomputeBranching(deps, dep, stack, stackTop); + } + + stackTop += 1; + stack[stackTop] = link; + link = deps; + consumer = dep; + continue; + } + + changed = refreshDependency(link, dep, depState); + break; + } + + clearInvalid(consumer); + + if (stackTop < 0) return false; + + link = stack[stackTop]!; + stackTop -= 1; + consumer = link.to; + } + + while (stackTop >= 0) { + const parentLink = stack[stackTop]!; + stackTop -= 1; + + if (changed) { + changed = refreshDependency(parentLink, consumer); + } else { + clearInvalid(consumer); + } + + consumer = parentLink.to; + } + + if (!changed) clearInvalid(consumer); return changed; } @@ -109,113 +260,5 @@ export function shouldRecompute(node: ReactiveNode): boolean { return false; } - // Stack stores return points for the DFS. - // Each entry is the edge we descended through; on the way back up - // we use it to resume the parent consumer's remaining siblings. - // - // Example — descending into C (dep of B): - // stack: [ edge(B→C) ] - // After C's subtree resolves, pop → resume B's next dep (B→D). - const stack: ReactiveEdge[] = []; - let stackTop = -1; - - let link = firstIn; // current edge being inspected - let sub = node; // consumer whose incoming edges we are walking - let dirty = false; // true once any upstream change is confirmed - - outer: while (true) { - const dep = link.from; - const depState = dep.state; - - if ((sub.state & ReactiveNodeState.Changed) !== 0) { - // sub itself was confirmed changed while we were descending into it - // (e.g. propagateOnce ran concurrently from a fanout sibling). - // No need to inspect further deps of sub — just mark dirty and unwind. - dirty = true; - } else if ((depState & ReactiveNodeState.Changed) !== 0) { - // dep is already confirmed changed (producer or previously refreshed computed). - // Refresh it (clears dirty bits, may propagate to fanout siblings) - // and record whether its value actually differs. - // - // Example: signal A was set to a new value → A.Changed=true - // refreshDependency returns true → dirty=true → unwind and return true. - dirty = dirty || refreshDependency(link, dep); - } else if ( - (depState & ReactiveNodeState.Producer) === 0 && - (depState & DIRTY_STATE) !== 0 - ) { - // dep is a dirty computed node with its own dependencies. - // We can't know if it truly changed without inspecting its subtree first. - // Descend: push current position as a return point and move into dep. - // - // Example: B depends on C (computed, Invalid), C depends on A (signal). - // We don't know if C changed until we check A. - // Push edge(B→C), set link=C.firstIn, sub=C, then loop again. - const statusbar = dep.firstIn; - if (statusbar !== null) { - stack[++stackTop] = link; - link = statusbar; - sub = dep; - continue; - } - - dirty = dirty || refreshDependency(link, dep); - } - - if (!dirty) { - // Still no confirmed change. Try next sibling dep of the current consumer. - // - // Example: sub=B, checked dep C (clean), now check dep D. - // link = link.nextIn → edge(B→D), continue outer loop. - if (link.nextIn !== null) { - link = link.nextIn; - continue; - } - - // All deps of sub checked out clean → sub is no longer invalid. - sub.state = sub.state & ~ReactiveNodeState.Invalid; - } - - // ── Unwind DFS stack ────────────────────────────────────────────────────── - // - // Either dirty=true (change confirmed, propagate up) or we exhausted sub's - // deps cleanly and need to return to the parent consumer. - // - // Example unwind (dirty=true): - // stack: [ edge(B→C) ] sub=C dirty=true - // Pop edge(B→C): refreshDependency(edge(B→C), C) - // → reruns C, returns whether C's value changed - // → dirty = that result (C might have re-computed to same value → false) - // sub = B, link = edge(B→C) - // dirty=false now? check B's next dep (B→D) before returning. - // - // Example unwind (dirty=false): - // stack: [ edge(B→C) ] sub=C dirty=false - // Pop edge(B→C): clear C's Invalid flag, keep dirty=false. - // sub = B, link = edge(B→C) - // Check B's next dep (B→D). - while (stackTop >= 0) { - const parentLink = stack[stackTop--]!; - - if (dirty) { - dirty = refreshDependency(parentLink, sub); - } else { - sub.state = sub.state & ~ReactiveNodeState.Invalid; - } - - sub = parentLink.to; - link = parentLink; - - if (!dirty && link.nextIn !== null) { - // Parent is still clean and has more deps to inspect. - // Resume the outer loop at the next sibling rather than unwinding further. - link = link.nextIn; - continue outer; - } - } - - // Stack fully unwound. dirty reflects whether the original node's - // dependency subtree contained any real change. - return dirty; - } + return shouldRecomputeLinear(node, firstIn); } diff --git a/packages/@reflex/runtime/tests/runtime.e2e.mjs b/packages/@reflex/runtime/tests/mjs/runtime.e2e.mjs similarity index 100% rename from packages/@reflex/runtime/tests/runtime.e2e.mjs rename to packages/@reflex/runtime/tests/mjs/runtime.e2e.mjs diff --git a/packages/@reflex/runtime/tests/walkers.jit.mjs b/packages/@reflex/runtime/tests/walkers.jit.mjs new file mode 100644 index 0000000..25e9168 --- /dev/null +++ b/packages/@reflex/runtime/tests/walkers.jit.mjs @@ -0,0 +1,678 @@ +import { performance } from "node:perf_hooks"; +import { + ConsumerReadMode, + readConsumer, + readProducer, +} from "../build/esm/api/read.js"; +import { writeProducer } from "../build/esm/api/write.js"; +import runtime from "../build/esm/reactivity/context.js"; +import { recompute } from "../build/esm/reactivity/engine/compute.js"; +import { executeNodeComputation } from "../build/esm/reactivity/engine/execute.js"; +import { + CONSUMER_CHANGED, + PRODUCER_INITIAL_STATE, + ReactiveNodeState, +} from "../build/esm/reactivity/shape/ReactiveMeta.js"; +import { UNINITIALIZED } from "../build/esm/reactivity/shape/ReactiveNode.js"; +import ReactiveNode from "../build/esm/reactivity/shape/ReactiveNode.js"; +import { linkEdge } from "../build/esm/reactivity/shape/methods/connect.js"; +import { propagate } from "../build/esm/reactivity/walkers/propagate.js"; +import { shouldRecompute } from "../build/esm/reactivity/walkers/shouldRecompute.js"; + +const DIRTY_OR_WALKER = + ReactiveNodeState.Invalid | + ReactiveNodeState.Changed | + ReactiveNodeState.Visited | + ReactiveNodeState.Tracking; +const CONSUMER_INITIAL_STATE = CONSUMER_CHANGED; +const TRACKING_CONSUMER_STATE = + ReactiveNodeState.Consumer | ReactiveNodeState.Tracking; + +function createProducer(value) { + return new ReactiveNode(value, null, PRODUCER_INITIAL_STATE); +} + +function createConsumer(compute) { + return new ReactiveNode(UNINITIALIZED, compute, CONSUMER_INITIAL_STATE); +} + +function resetRuntime() { + runtime.resetState(); + runtime.setHooks({}); +} + +function clearWalkerState(nodes) { + for (let i = 0; i < nodes.length; i += 1) { + nodes[i].state &= ~DIRTY_OR_WALKER; + } +} + +function buildPropagateChain(depth) { + resetRuntime(); + + const root = createProducer(0); + const nodes = []; + let parent = root; + + for (let i = 0; i < depth; i += 1) { + const node = createConsumer(() => i); + nodes.push(node); + linkEdge(parent, node); + parent = node; + } + + const startEdge = root.firstOut; + if (startEdge === null) throw new Error("propagate chain root has no edge"); + + return { + nodes, + run() { + propagate(startEdge, true); + clearWalkerState(nodes); + return nodes.length; + }, + }; +} + +function buildPropagateFanout(width, depth) { + resetRuntime(); + + const root = createProducer(0); + const nodes = []; + + for (let i = 0; i < width; i += 1) { + let parent = createConsumer(() => i); + nodes.push(parent); + linkEdge(root, parent); + + for (let j = 1; j < depth; j += 1) { + const child = createConsumer(() => i + j); + nodes.push(child); + linkEdge(parent, child); + parent = child; + } + } + + const startEdge = root.firstOut; + if (startEdge === null) throw new Error("propagate fanout root has no edge"); + + return { + nodes, + run() { + propagate(startEdge, true); + clearWalkerState(nodes); + return nodes.length; + }, + }; +} + +function buildTrackedPrefix(fanIn, trackedCount) { + resetRuntime(); + + const target = createConsumer(() => 0); + const producers = []; + const edges = []; + + for (let i = 0; i < fanIn; i += 1) { + const producer = createProducer(i); + producers.push(producer); + edges.push(linkEdge(producer, target)); + } + + const trackedEdge = edges[trackedCount - 1]; + const prefixEdge = edges[0]; + const staleEdge = edges[edges.length - 1]; + + if (!trackedEdge || !prefixEdge || !staleEdge) { + throw new Error("tracked-prefix graph is incomplete"); + } + + function resetTrackingState() { + target.state = TRACKING_CONSUMER_STATE; + target.depsTail = trackedEdge; + } + + return { + prefix() { + resetTrackingState(); + propagate(prefixEdge, true); + return target.state; + }, + stale() { + resetTrackingState(); + propagate(staleEdge, true); + return target.state; + }, + }; +} + +function buildTrackedPrefixStress(fanIn, depsTailIndex, edgeIndex) { + resetRuntime(); + + const target = createConsumer(() => 0); + const producers = []; + const edges = []; + + for (let i = 0; i < fanIn; i += 1) { + const producer = createProducer(i); + producers.push(producer); + edges.push(linkEdge(producer, target)); + } + + const depsTail = edges[depsTailIndex]; + const targetEdge = edges[edgeIndex]; + + if (!depsTail || !targetEdge) { + throw new Error("tracked-prefix stress graph is incomplete"); + } + + return { + run() { + target.state = TRACKING_CONSUMER_STATE; + target.depsTail = depsTail; + propagate(targetEdge, true); + return target.state; + }, + }; +} + +function buildPropagateBranchingTrackingMix(width, depth) { + resetRuntime(); + + const root = createProducer(0); + const nodes = []; + const trackingAccept = []; + const trackingReject = []; + + for (let i = 0; i < width; i += 1) { + const branchRoot = createConsumer(() => i); + nodes.push(branchRoot); + const rootEdge = linkEdge(root, branchRoot); + + if ((i & 3) === 0) { + trackingAccept.push({ node: branchRoot, depsTail: rootEdge }); + } else if ((i & 3) === 1) { + const prefix = createProducer(-(i + 1)); + const prefixEdge = linkEdge(prefix, branchRoot, null); + trackingReject.push({ node: branchRoot, depsTail: prefixEdge }); + } + + let parent = branchRoot; + + for (let j = 1; j < depth; j += 1) { + const child = createConsumer(() => i + j); + nodes.push(child); + + if (((i + j) & 7) === 2) { + const prefix = createProducer(-(i * depth + j + 1)); + const prefixEdge = linkEdge(prefix, child, null); + const edge = linkEdge(parent, child); + trackingReject.push({ node: child, depsTail: prefixEdge }); + parent = child; + void edge; + continue; + } + + const edge = linkEdge(parent, child); + if (((i + j) & 7) === 0) { + trackingAccept.push({ node: child, depsTail: edge }); + } + + parent = child; + } + } + + const startEdge = root.firstOut; + if (startEdge === null) throw new Error("branching tracking mix root has no edge"); + + function armTracking() { + clearWalkerState(nodes); + + for (let i = 0; i < trackingAccept.length; i += 1) { + const entry = trackingAccept[i]; + entry.node.state = TRACKING_CONSUMER_STATE; + entry.node.depsTail = entry.depsTail; + } + + for (let i = 0; i < trackingReject.length; i += 1) { + const entry = trackingReject[i]; + entry.node.state = TRACKING_CONSUMER_STATE; + entry.node.depsTail = entry.depsTail; + } + } + + return { + run() { + armTracking(); + propagate(startEdge, true); + return nodes.length; + }, + }; +} + +function buildShouldRecomputeChain(depth) { + resetRuntime(); + + const source = createProducer(0); + let parent = source; + + for (let i = 0; i < depth; i += 1) { + const previous = parent; + parent = createConsumer(() => readConsumer(previous) + 1); + } + + const root = parent; + readConsumer(root); + + let value = 0; + + return { + run() { + value += 1; + writeProducer(source, value); + const dirty = shouldRecompute(root); + if (!dirty) throw new Error("expected dirty root"); + recompute(root); + return root.payload; + }, + }; +} + +function buildShouldRecomputeDiamond() { + resetRuntime(); + + const source = createProducer(0); + const shared = createConsumer(() => readProducer(source) + 1); + const left = createConsumer(() => readConsumer(shared) + 1); + const right = createConsumer(() => readConsumer(shared) + 2); + const root = createConsumer(() => readConsumer(left) + readConsumer(right)); + + readConsumer(root); + + let value = 0; + + return { + run() { + value += 1; + writeProducer(source, value); + const dirty = shouldRecompute(root); + if (!dirty) throw new Error("expected dirty diamond root"); + recompute(root); + return root.payload; + }, + }; +} + +function buildExecuteNodeComputationStatic(fanIn) { + resetRuntime(); + + const sources = []; + + for (let i = 0; i < fanIn; i += 1) { + sources.push(createProducer(i)); + } + + const node = createConsumer(() => { + let sum = 0; + + for (let i = 0; i < sources.length; i += 1) { + sum += readProducer(sources[i]); + } + + return sum; + }); + + readConsumer(node); + + let value = 0; + + return { + run() { + value += 1; + sources[0].payload = value; + return executeNodeComputation(node, (result) => { + node.payload = result; + return result; + }); + }, + }; +} + +function buildExecuteNodeComputationChurn(fanIn, narrowWidth) { + resetRuntime(); + + const sources = []; + + for (let i = 0; i < fanIn; i += 1) { + sources.push(createProducer(i)); + } + + let wide = true; + const node = createConsumer(() => { + let sum = 0; + const limit = wide ? fanIn : narrowWidth; + + for (let i = 0; i < limit; i += 1) { + sum += readProducer(sources[i]); + } + + return sum; + }); + + readConsumer(node); + + let value = 0; + + return { + run() { + wide = !wide; + value += 1; + sources[0].payload = value; + return executeNodeComputation(node, (result) => { + node.payload = result; + return result; + }); + }, + }; +} + +function buildRecomputeStatic(fanIn) { + resetRuntime(); + + const sources = []; + + for (let i = 0; i < fanIn; i += 1) { + sources.push(createProducer(i)); + } + + const node = createConsumer(() => { + let sum = 0; + + for (let i = 0; i < sources.length; i += 1) { + sum += readProducer(sources[i]); + } + + return sum; + }); + + readConsumer(node); + + let value = 0; + + return { + run() { + value += 1; + sources[0].payload = value; + node.state |= ReactiveNodeState.Invalid; + return recompute(node) ? 1 : 0; + }, + }; +} + +function buildRecomputeChurn(fanIn, narrowWidth) { + resetRuntime(); + + const sources = []; + + for (let i = 0; i < fanIn; i += 1) { + sources.push(createProducer(i)); + } + + let wide = true; + const node = createConsumer(() => { + let sum = 0; + const limit = wide ? fanIn : narrowWidth; + + for (let i = 0; i < limit; i += 1) { + sum += readProducer(sources[i]); + } + + return sum; + }); + + readConsumer(node); + + let value = 0; + + return { + run() { + wide = !wide; + value += 1; + sources[0].payload = value; + node.state |= ReactiveNodeState.Invalid; + return recompute(node) ? 1 : 0; + }, + }; +} + +function buildReadConsumerDirtyChain(depth, mode = ConsumerReadMode.lazy) { + resetRuntime(); + + const source = createProducer(0); + let parent = source; + + for (let i = 0; i < depth; i += 1) { + const previous = parent; + parent = createConsumer(() => readConsumer(previous) + 1); + } + + const root = parent; + readConsumer(root); + + let value = 0; + + return { + run() { + value += 1; + writeProducer(source, value); + return readConsumer(root, mode); + }, + }; +} + +function buildWriteProducerNoSubscribers() { + resetRuntime(); + + const source = createProducer(0); + let value = 0; + + return { + run() { + value += 1; + writeProducer(source, value); + return value; + }, + }; +} + +function buildWriteProducerFanout(width, depth) { + resetRuntime(); + + const source = createProducer(0); + const nodes = []; + + for (let i = 0; i < width; i += 1) { + let parent = createConsumer(() => i); + nodes.push(parent); + linkEdge(source, parent); + + for (let j = 1; j < depth; j += 1) { + const child = createConsumer(() => i + j); + nodes.push(child); + linkEdge(parent, child); + parent = child; + } + } + + let value = 0; + + return { + run() { + value += 1; + writeProducer(source, value); + clearWalkerState(nodes); + return nodes.length; + }, + }; +} + +function warm(fn, iterations) { + let sink = 0; + + for (let i = 0; i < iterations; i += 1) { + sink ^= fn(i) & 1; + } + + return sink; +} + +function bench(label, fn, iterations, warmup = iterations) { + warm(fn, warmup); + + if (globalThis.gc) { + globalThis.gc(); + } + + let sink = 0; + const start = performance.now(); + + for (let i = 0; i < iterations; i += 1) { + sink ^= fn(i) & 1; + } + + const elapsedMs = performance.now() - start; + const nsPerOp = (elapsedMs * 1e6) / iterations; + console.log(`${label}: ${nsPerOp.toFixed(1)} ns/op | sink=${sink}`); +} + +function runBenchSuite() { + const chain = buildPropagateChain(64); + const fanout = buildPropagateFanout(32, 8); + const trackedPrefix = buildTrackedPrefix(128, 64); + const recomputeChain = buildShouldRecomputeChain(32); + const recomputeDiamond = buildShouldRecomputeDiamond(); + + bench("propagate_chain", () => chain.run(), 200000); + bench("propagate_fanout", () => fanout.run(), 100000); + bench("tracked_prefix_hit", () => trackedPrefix.prefix(), 200000); + bench("tracked_prefix_miss", () => trackedPrefix.stale(), 200000); + bench("shouldRecompute_chain", () => recomputeChain.run(), 100000, 50000); + bench("shouldRecompute_diamond", () => recomputeDiamond.run(), 100000, 50000); +} + +function runEngineBenchSuite() { + const executeStatic = buildExecuteNodeComputationStatic(32); + const executeChurn = buildExecuteNodeComputationChurn(32, 16); + const recomputeStatic = buildRecomputeStatic(32); + const recomputeChurn = buildRecomputeChurn(32, 16); + const readLazyChain = buildReadConsumerDirtyChain(32, ConsumerReadMode.lazy); + const readEagerChain = buildReadConsumerDirtyChain(32, ConsumerReadMode.eager); + const writeNoSubscribers = buildWriteProducerNoSubscribers(); + const writeFanout = buildWriteProducerFanout(32, 8); + + bench("executeNodeComputation_static", () => executeStatic.run(), 100000, 50000); + bench("executeNodeComputation_churn", () => executeChurn.run(), 100000, 50000); + bench("recompute_static", () => recomputeStatic.run(), 100000, 50000); + bench("recompute_churn", () => recomputeChurn.run(), 100000, 50000); + bench("api_readConsumer_lazy_chain", () => readLazyChain.run(), 100000, 50000); + bench("api_readConsumer_eager_chain", () => readEagerChain.run(), 100000, 50000); + bench("api_writeProducer_no_subscribers", () => writeNoSubscribers.run(), 300000, 100000); + bench("api_writeProducer_fanout", () => writeFanout.run(), 100000, 50000); +} + +function runSingleScenario(name) { + switch (name) { + case "tracked_prefix_stress_true": { + const scenario = buildTrackedPrefixStress(1024, 768, 767); + bench(name, () => scenario.run(), 200000); + return; + } + case "tracked_prefix_stress_false": { + const scenario = buildTrackedPrefixStress(1024, 31, 1023); + bench(name, () => scenario.run(), 200000); + return; + } + case "propagate_branching_tracking_mix": { + const scenario = buildPropagateBranchingTrackingMix(32, 8); + bench(name, () => scenario.run(), 100000); + return; + } + case "shouldRecompute_chain": { + const scenario = buildShouldRecomputeChain(32); + bench(name, () => scenario.run(), 100000, 50000); + return; + } + case "shouldRecompute_diamond": { + const scenario = buildShouldRecomputeDiamond(); + bench(name, () => scenario.run(), 100000, 50000); + return; + } + case "executeNodeComputation_static": { + const scenario = buildExecuteNodeComputationStatic(32); + bench(name, () => scenario.run(), 100000, 50000); + return; + } + case "executeNodeComputation_churn": { + const scenario = buildExecuteNodeComputationChurn(32, 16); + bench(name, () => scenario.run(), 100000, 50000); + return; + } + case "recompute_static": { + const scenario = buildRecomputeStatic(32); + bench(name, () => scenario.run(), 100000, 50000); + return; + } + case "recompute_churn": { + const scenario = buildRecomputeChurn(32, 16); + bench(name, () => scenario.run(), 100000, 50000); + return; + } + case "api_readConsumer_lazy_chain": { + const scenario = buildReadConsumerDirtyChain(32, ConsumerReadMode.lazy); + bench(name, () => scenario.run(), 100000, 50000); + return; + } + case "api_readConsumer_eager_chain": { + const scenario = buildReadConsumerDirtyChain(32, ConsumerReadMode.eager); + bench(name, () => scenario.run(), 100000, 50000); + return; + } + case "api_writeProducer_no_subscribers": { + const scenario = buildWriteProducerNoSubscribers(); + bench(name, () => scenario.run(), 300000, 100000); + return; + } + case "api_writeProducer_fanout": { + const scenario = buildWriteProducerFanout(32, 8); + bench(name, () => scenario.run(), 100000, 50000); + return; + } + default: + throw new Error(`Unknown scenario: ${name}`); + } +} + +function main() { + const mode = process.argv[2] ?? "bench"; + + if (mode === "bench") { + runBenchSuite(); + return; + } + + if (mode === "engine") { + runEngineBenchSuite(); + return; + } + + if (mode === "scenario") { + const name = process.argv[3]; + if (!name) throw new Error("scenario name is required"); + runSingleScenario(name); + return; + } + + throw new Error(`Unknown mode: ${mode}`); +} + +main(); diff --git a/packages/reflex-dom/src/ownership/ownership.cleanup.ts b/packages/reflex-dom/src/ownership/ownership.cleanup.ts index 209ca4f..05567a2 100644 --- a/packages/reflex-dom/src/ownership/ownership.cleanup.ts +++ b/packages/reflex-dom/src/ownership/ownership.cleanup.ts @@ -1,53 +1,47 @@ +import type { Cleanup } from "src/types"; import { isDisposed, markDisposed } from "./ownership.meta"; -import { OwnershipNode } from "./ownership.node"; +import type { OwnershipNode } from "./ownership.node"; import { detach } from "./ownership.tree"; -export function addCleanup(node: OwnershipNode, fn: () => void): void { +export function addCleanup(node: OwnershipNode, fn: Cleanup): void { if (isDisposed(node)) return; const cleanups = node.cleanups; if (cleanups === null) { node.cleanups = fn; - return; - } - - if (typeof cleanups === "function") { + } else if (typeof cleanups === "function") { node.cleanups = [cleanups, fn]; - return; + } else { + cleanups.push(fn); } - - cleanups.push(fn); } function reportCleanupError(error: unknown): void { console.error("Ownership cleanup error:", error); } +function invokeCleanup(fn: Cleanup): void { + try { + fn(); + } catch (error) { + reportCleanupError(error); + } +} + function runCleanups(node: OwnershipNode): void { const cleanups = node.cleanups; - node.cleanups = null; + if (cleanups === null) return; - if (cleanups === null) { - return; - } + node.cleanups = null; if (typeof cleanups === "function") { - try { - cleanups(); - } catch (error) { - reportCleanupError(error); - } - + invokeCleanup(cleanups); return; } - for (let index = cleanups.length - 1; index >= 0; index--) { - try { - cleanups[index]?.(); - } catch (error) { - reportCleanupError(error); - } + for (let i = cleanups.length - 1; i >= 0; i--) { + invokeCleanup(cleanups[i]!); } } @@ -58,14 +52,13 @@ export function dispose(root: OwnershipNode): void { while (node !== null) { const child: OwnershipNode | null = node.firstChild; - if (child !== null) { node = child; continue; } - const nextSibling: OwnershipNode | null = node.nextSibling; - const parent: OwnershipNode | null = node.parent; + const next: OwnershipNode | null = + node === root ? null : (node.nextSibling ?? node.parent); runCleanups(node); markDisposed(node); @@ -75,6 +68,6 @@ export function dispose(root: OwnershipNode): void { node.lastChild = null; node.context = null; - node = node === root ? null : (nextSibling ?? parent); + node = next; } } diff --git a/packages/reflex-dom/src/structure/content-slot.ts b/packages/reflex-dom/src/structure/content-slot.ts index 56ce9a8..62c71a0 100644 --- a/packages/reflex-dom/src/structure/content-slot.ts +++ b/packages/reflex-dom/src/structure/content-slot.ts @@ -4,25 +4,10 @@ import { createScope, disposeScope, type Scope } from "../ownership"; export type MountUnknown = (parent: Node, scope: Scope, value: unknown) => void; type ContentState = - | { - kind: "empty"; - scope: null; - } - | { - kind: "text"; - scope: null; - node: Text; - value: string; - } - | { - kind: "node"; - scope: null; - node: Node; - } - | { - kind: "fallback"; - scope: Scope; - }; + | { kind: "empty" } + | { kind: "text"; node: Text; value: string } + | { kind: "node"; node: Node } + | { kind: "fallback"; scope: Scope }; export interface ContentSlot { fragment: DocumentFragment; @@ -51,165 +36,132 @@ function isSingleNodeValue(value: unknown): value is Node { ); } -function disposeState(state: ContentState): void { - if (state.scope !== null) { - disposeScope(state.scope); - } -} - export function createContentSlot( doc: Document, mountUnknown: MountUnknown, initialValue: unknown, ): ContentSlot { + const fragment = doc.createDocumentFragment(); const start = doc.createComment(""); const end = doc.createComment(""); - const fragment = doc.createDocumentFragment(); let destroyed = false; - let state: ContentState = { kind: "empty", scope: null }; - function mountIntoParent(parent: Node, value: unknown): void { + let state: ContentState = { kind: "empty" }; + + function unmountCurrent(): void { + if (state.kind === "fallback") { + disposeScope(state.scope); + } + + const parent = start.parentNode; + if (parent !== null && end.parentNode === parent) { + clearBetween(start, end); + } + + state = { kind: "empty" }; + } + + function mount(parent: Node, value: unknown): void { if (isEmptyValue(value)) { - state = { kind: "empty", scope: null }; + state = { kind: "empty" }; return; } if (isTextValue(value)) { - const node = doc.createTextNode(String(value)); - parent.appendChild(node); + const text = doc.createTextNode(value + ""); + parent.insertBefore(text, end); state = { kind: "text", - scope: null, - node, - value: node.data, + node: text, + value: text.data, }; return; } if (isSingleNodeValue(value)) { - parent.appendChild(value); + parent.insertBefore(value, end); state = { kind: "node", - scope: null, node: value, }; return; } const scope = createScope(); - mountUnknown(parent, scope, value); + const content = doc.createDocumentFragment(); + mountUnknown(content, scope, value); + parent.insertBefore(content, end); state = { kind: "fallback", scope, }; } - function mountInitialState(parent: Node, value: unknown): void { - parent.appendChild(start); - mountIntoParent(parent, value); - parent.appendChild(end); - } - - mountInitialState(fragment, initialValue); - - function clearMountedContent(): void { - disposeState(state); - - const parent = start.parentNode; - if (parent !== null && end.parentNode === parent) { - clearBetween(start, end); - } - - state = { kind: "empty", scope: null }; - } - - function updateText(parent: Node, value: string): void { - if (state.kind === "text") { - if (state.value !== value) { - state.node.data = value; - state.value = value; - } - return; - } - - clearMountedContent(); - - const node = doc.createTextNode(value); - parent.insertBefore(node, end); - state = { - kind: "text", - scope: null, - node, - value, - }; - } - - function updateNode(parent: Node, value: Node): void { - if (state.kind === "node" && state.node === value) { - return; - } - - clearMountedContent(); - parent.insertBefore(value, end); - state = { - kind: "node", - scope: null, - node: value, - }; - } - - function updateFallback(parent: Node, value: unknown): void { - clearMountedContent(); - - const nextScope = createScope(); - const nextFragment = doc.createDocumentFragment(); - mountUnknown(nextFragment, nextScope, value); - parent.insertBefore(nextFragment, end); - state = { - kind: "fallback", - scope: nextScope, - }; - } + fragment.appendChild(start); + fragment.appendChild(end); + mount(fragment, initialValue); return { fragment, start, end, - update(value) { + + update(value: unknown): void { if (destroyed) return; const parent = end.parentNode; if (parent === null) return; if (isEmptyValue(value)) { - clearMountedContent(); + if (state.kind !== "empty") { + unmountCurrent(); + } return; } if (isTextValue(value)) { - updateText(parent, String(value)); + const next = value + ""; + + if (state.kind === "text") { + if (state.value !== next) { + state.node.data = next; + state.value = next; + } + return; + } + + unmountCurrent(); + mount(parent, next); return; } if (isSingleNodeValue(value)) { - updateNode(parent, value); + if (state.kind === "node" && state.node === value) { + return; + } + + unmountCurrent(); + mount(parent, value); return; } - updateFallback(parent, value); + unmountCurrent(); + mount(parent, value); }, - dispose() { + dispose(): void { if (destroyed) return; - clearMountedContent(); + if (state.kind !== "empty") { + unmountCurrent(); + } }, - - destroy() { + + destroy(): void { if (destroyed) return; - clearMountedContent(); - start.parentNode?.removeChild(start); - end.parentNode?.removeChild(end); + unmountCurrent(); + start.remove(); + end.remove(); destroyed = true; }, }; diff --git a/packages/reflex-dom/test/ownership.test.ts b/packages/reflex-dom/test/ownership.test.ts index 7db00de..233696f 100644 --- a/packages/reflex-dom/test/ownership.test.ts +++ b/packages/reflex-dom/test/ownership.test.ts @@ -3,6 +3,7 @@ import { createOwnerContext, createScope, disposeScope, + getChildCount, registerCleanup, runWithScope, } from "../src/ownership"; @@ -120,4 +121,94 @@ describe("ownership lifecycle", () => { consoleError.mockRestore(); } }); + + it("detaches a disposed subtree from a live root and clears sibling links", () => { + const owner = createOwnerContext(); + const root = createScope(); + let left: ReturnType; + let branch: ReturnType; + let right: ReturnType; + let grandChild: ReturnType; + + runWithScope(owner, root, () => { + left = createScope(); + runWithScope(owner, left, () => {}); + + branch = createScope(); + runWithScope(owner, branch, () => { + grandChild = createScope(); + runWithScope(owner, grandChild, () => {}); + }); + + right = createScope(); + runWithScope(owner, right, () => {}); + }); + + disposeScope(branch!); + + expect(root.firstChild).toBe(left!); + expect(root.lastChild).toBe(right!); + expect(left!.nextSibling).toBe(right!); + expect(right!.prevSibling).toBe(left!); + expect(getChildCount(root)).toBe(2); + + expect(branch!.parent).toBeNull(); + expect(branch!.prevSibling).toBeNull(); + expect(branch!.nextSibling).toBeNull(); + expect(branch!.firstChild).toBeNull(); + expect(branch!.lastChild).toBeNull(); + + expect(grandChild!.parent).toBeNull(); + expect(grandChild!.prevSibling).toBeNull(); + expect(grandChild!.nextSibling).toBeNull(); + }); + + it("keeps the live root consistent when subtree cleanup throws", () => { + const owner = createOwnerContext(); + const root = createScope(); + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); + const cleanupError = new Error("branch cleanup failed"); + let left: ReturnType; + let branch: ReturnType; + let right: ReturnType; + + try { + runWithScope(owner, root, () => { + left = createScope(); + runWithScope(owner, left, () => {}); + + branch = createScope(); + runWithScope(owner, branch, () => { + registerCleanup(owner, () => { + throw cleanupError; + }); + }); + + right = createScope(); + runWithScope(owner, right, () => {}); + }); + + expect(() => disposeScope(branch!)).not.toThrow(); + + expect(consoleError).toHaveBeenCalledTimes(1); + expect(consoleError).toHaveBeenCalledWith( + "Ownership cleanup error:", + cleanupError, + ); + + expect(root.firstChild).toBe(left!); + expect(root.lastChild).toBe(right!); + expect(left!.nextSibling).toBe(right!); + expect(right!.prevSibling).toBe(left!); + expect(getChildCount(root)).toBe(2); + + expect(branch!.parent).toBeNull(); + expect(branch!.prevSibling).toBeNull(); + expect(branch!.nextSibling).toBeNull(); + } finally { + consoleError.mockRestore(); + } + }); }); diff --git a/packages/reflex/src/infra/runtime.ts b/packages/reflex/src/infra/runtime.ts index 8f10dd6..d077151 100644 --- a/packages/reflex/src/infra/runtime.ts +++ b/packages/reflex/src/infra/runtime.ts @@ -7,11 +7,12 @@ import type { EngineHooks, ReactiveNode, } from "@reflex/runtime"; +import type { + EffectStrategy} from "../policy"; import { resolveEffectSchedulerMode, EffectScheduler, - EventDispatcher, - EffectStrategy, + EventDispatcher } from "../policy"; import { createSource } from "./factory"; diff --git a/packages/reflex/src/policy/effect_scheduler.ts b/packages/reflex/src/policy/effect_scheduler.ts index 483d673..ab8df1d 100644 --- a/packages/reflex/src/policy/effect_scheduler.ts +++ b/packages/reflex/src/policy/effect_scheduler.ts @@ -5,7 +5,7 @@ import { runtime, } from "@reflex/runtime"; import { effectScheduled, effectUnscheduled } from "../api/effect"; -import { UNINITIALIZED } from "../infra/factory"; +import type { UNINITIALIZED } from "../infra/factory"; import type { ReactiveNode } from "@reflex/runtime"; export const enum EffectSchedulerMode { diff --git a/packages/reflex/src/policy/runtimeScheduler.ts b/packages/reflex/src/policy/runtimeScheduler.ts new file mode 100644 index 0000000..2faf373 --- /dev/null +++ b/packages/reflex/src/policy/runtimeScheduler.ts @@ -0,0 +1,56 @@ +export interface RuntimeScheduler { + requestFlush(run: () => void): void; +} + +export interface RuntimeHost { + hasPendingCommitWork?(): boolean; + commit?(): void; + postCommit?(): void; +} + +export interface RuntimeOptions { + scheduler?: RuntimeScheduler; + host?: RuntimeHost; +} + +class Runtime { + private scheduled = false; + private flushing = false; + + constructor( + private readonly scheduler: RuntimeScheduler, + private readonly host: RuntimeHost | null, + ) {} + + schedule(): void { + if (this.scheduled) return; + this.scheduled = true; + + this.scheduler.requestFlush(() => this.flush()); + } + + flush(): void { + if (this.flushing) return; + this.flushing = true; + + try { + do { + this.scheduled = false; + + this.flushReactiveWork(); + + if (this.host?.hasPendingCommitWork?.()) { + this.host.commit?.(); + } + + this.host?.postCommit?.(); + } while (this.scheduled); + } finally { + this.flushing = false; + } + } + + private flushReactiveWork(): void { + // dirty nodes / effects / graph propagation + } +}