pyreon

Lifecycle & Cleanup Mistakes

Generated from .claude/rules/anti-patterns.md (the same source as MCP get_anti_patterns). Each entry is a real mistake + its fix; where a detector code is listed, the linter / pyreon doctor / MCP validate catches it automatically.

A ResizeObserver callback wired to signal writes must bail when its element is detached (!el.isConnected)

disconnect() in the unmount ref stops FUTURE observations, but a batch already queued can still deliver one late tick — the callback then writes a bogus detached-size (0×0 gBCR) into the reactive graph MID-TEARDOWN, re-running effects whose owner context is being disposed (CI-observed as an unhandled null read in an overlay's render effect; load-dependent, invisible locally). Worse for measure-then-clear pairs: a stale tick after the ref's _clearNodeMeasurement(id) RE-ADDS the entry for an unmounted node. if (!el.isConnected) return at the top of every RO callback that writes signals. Reference: @pyreon/flow flow-component.tsx (container updateSize + per-node measure).


A suspend/mutate/resume window over SHARED subscriber state must be exception-safe — hoist reads that can throw OUT of the window, resume in finally, and reset any in-flight flag in finally (the @pyreon/store patch() instance, 2026-07)

@pyreon/store's with-subscriber patch() fast path (#2286) DETACHES each patched field's change-detector from the signal's subscriber set (_suspendSubscriber = a disposer-free _s.delete), writes the field, then re-attaches (_resumeSubscriber) — so its own writes don't round-trip the reactivity batch queue purely to rebuild change info it already has. Three throw windows made this un-exception-safe: (1) const newValue = arg[key] was read AFTER _suspendSubscriber, so a THROWING GETTER / Proxy on the patch object (patch({ get b() { throw } })) aborted with b's detector DELETED and never resumed → every subsequent DIRECT write (store.b.set(v)) was silently un-notified until the next patch() touching b re-added it; (2) sig.set(newValue) (a wrapped signal whose write side-effect can throw — a storage-backed field on quota) between suspend and resume skipped the resume on throw — same detached-detector leak; (3) patchInProgress = false + the deferred-event merge sat AFTER the batch drain with no finally, so a raw field.subscribe listener throwing straight past the effect queue (reactivity lets raw listeners throw past drainQueuesLocked) left the flag STUCK true → notifyDirect then BUFFERED every later direct write's event into patchEvents instead of emitting it (silent event drop; the flag half predates #2286 — old code silently DROPPED the deferred events instead). Fix (zero hot-path cost — V8 zero-cost exceptions on the non-throwing path; bench held 154→142ns with-subscriber patch, within noise of the #2286 146ns win): hoist the arg[key] read BEFORE _suspendSubscriber (a throwing getter leaves the detector ATTACHED); wrap the per-key write try { sig.set(v) } finally { _resumeSubscriber(sig, det) }; put patchInProgress = false + the event merge + the single emit in a finally around the whole batch drain (so fields written BEFORE the throw still emit ONE patch notification — a partial patch is never silently dropped). Same discipline applied to the functional-form path (patchArg/flag/emit in a finally around batch(ensureApply())). The general rule (same family as the module-level thread-local "save-then-RESTORE, never reset-to-a-constant" reactivity entry + the Class-A position-based-pop entries): any code that mutates SHARED subscriber/registry/flag state across a window — detach→write→reattach, set-flag→drain→clear-flag — must (a) hoist every read that can throw OUT of the window, (b) restore/reattach in finally, and (c) reset any in-progress flag + flush any buffered work in finally. A throw between detach and reattach that skips the reattach silently un-notifies the field; a throw that skips the flag reset wedges every later notification. Bisect-verified: packages/fundamentals/store/src/tests/patch-exception-safety.test.ts (revert → 6 specs fail with expected 1 to have a length of +0 — 0 notifications on the post-throw direct write; restore → 9/9 pass; happy-path invariants — with-subscriber patch fires once, effect-reading-two-fields fires ONCE, re-entrant patch delivers both events — hold throughout). Reference: packages/fundamentals/store/src/index.ts (the patch() value-form detach path + functional path). 2026-07 follow-up (the discipline held under optimization): the per-key suspension moved to an O(1) _suspendSoleSubscriber Set SWAP (_s = null + restore-same-Set — the function-key Set.delete/add hashing was measured as ~25% of the whole with-subscriber patch) with the SAME finally-restore shape, guarded by a detectorEpoch counter so a mid-patch side-effect that re-wires the detectors (a hostile getter unsubscribing the last store subscriber) falls back to the per-listener path instead of wholesale-suspending a USER listener left sole on the field — the swap's "sole _s entry IS our detector" precondition is caller-guaranteed (verifying identity would need the exact hash op the path avoids), so the guard is the load-bearing safety (bisect: guard removed → patch-exception-safety.test.ts "mid-patch store-unsubscribe" fails with 0 user notifications).


Content mounted into a LIVE parent (a Portal target) that returns no explicit remover leaks its DOM on dispose (the <Portal> instance, 2026-07)

mountChild(children, target, anchor) returns a cleanup that, for element children, is a noop when _elementDepth > 0 — valid ONLY because the child is part of a freshly-built element removed as a UNIT (its parent's removeChild drops all descendants). <Portal>'s mount (mount.ts:PortalSymbol branch) mounts children directly into target (e.g. document.body) — a LIVE parent that is NEVER removed as a unit — so the returned cleanup left the portaled DOM behind. A modal / toast / tooltip / dropdown stayed in the document FOREVER once its owner unmounted (route change, <Show> flip, conditional render, keyed-<For> removal) — a growing pile of stale overlays + a real memory leak, entirely uncovered by tests (no spec asserted portal REMOVAL on dispose, which is how it shipped). Fix shape: bracket the portaled content with comment markers (<!--portal-->…<!--/portal-->), mount BEFORE the end marker (so reactive content that grows/shrinks stays inside the bracket), and on dispose remove everything between the markers + the markers themselves. The general rule (same family as the mountChild text-node _elementDepth gate + the SSR↔hydration parity "noop cleanup is valid only when the node is removed as part of a freshly-built element"): any framework code that mounts content into a parent it does NOT own/remove — a portal target, a document-level host, an existing app node — MUST return a real remover, never rely on the _elementDepth > 0 noop. Bisect-verified: packages/core/runtime-dom/src/tests/portal-dispose.test.tsx (static + reactive-grown content both fail with expected <div> to be null when the marker-removal is reverted to mountChild(children, target, null)). Discovered via the @pyreon/testing audit — render() + cleanup() left portaled modals in document.body across tests, which traced to this framework-level leak. Reference: packages/core/runtime-dom/src/mount.ts (PortalSymbol branch).


Module-level WeakSet/WeakMap membership registries fed by high-churn objects retain their GROWN BACKING TABLE forever (Class C variant — the For/keyed anchor-registry instance, 2026-07)

a WeakSet never pins its KEYS, so it reads as leak-proof — but V8 never SHRINKS an ephemeron hash table after growth, so a module-level registry that every list row passes through (_forAnchors/_keyedAnchors got one add() per row; a 10k-row bench suite pushed the table to 32768 slots) retains the 256KB table for the page's lifetime even after every key is GC'd. Heap-snapshot signature: one huge array: node retained via internal "table" from a WeakSet in a module scope — it was the ENTIRE retained-heap delta vs Solid on the krausest-style bench (3.16MB → 2.90MB when fixed). Fix shape: don't register high-churn objects in a module-level weak collection to answer a membership question — carry the answer on the OWNING record instead. Here, entries record their DOM extent (ForEntry.end/KeyedEntry.end, null = single node) at mount, so moveEntryBefore moves the exact [anchor..end] range with no registry at all (also removes a per-row WeakSet.add from the create path and makes multi-node moves exact instead of neighbor-sniffing). Bisect-verified by tests/for-entry-range-move.test.tsx (5 specs fail when the range walk is broken). Detection lesson: invisible to the heap-slope leak-sweep (the table stops growing once it reaches workload high-water — flat slope) AND to GC-observable WeakRef tests (keys DO die); only heap-snapshot attribution by constructor/retainer names it. Rule: before adding a module-level Weak collection on a per-item hot path, ask "what is this table's high-water capacity, and who ever releases it?" — weak keys don't make the TABLE weak.


Long-lived SCRATCH arrays holding object references after the pass that filled them (Class H — the mountFor LIS instance, 2026-07)

a per-instance scratch buffer that outlives its pass (LisState.entries lives as long as the <For>) and is only ever overwritten [0..newN) retains the stale tail [newN..oldN) FOREVER when the list shrinks — here, every removed row's ForEntry → its anchor DOM subtree + cleanup closure → disposers → signal subscriber links. Fix shape: scratch.fill(undefined, 0, n) at the end of the pass that filled it (O(n) writes on an already-O(n log n) path — free). Typed-array scratch (Int32Array etc.) holds numbers and can stay as capacity; ONLY reference-typed scratch needs the release. Detection lesson — the heap-slope leak-sweep is structurally blind to this class: its journeys run at CONSTANT size, so the scratch is fully overwritten every pass and the slope stays flat; the failure mode needs reorder-at-large-N → SHRINK → stay-mounted. The deterministic lock is a GC-observable unit test (WeakRefs on removed rows' DOM + --expose-gc via the package vitest config's overrides: { test: { execArgv } } — vitest 4 removed poolOptions) — bisect-verified: 60/62 removed rows stayed pinned with the release line reverted. Reference: packages/core/runtime-dom/src/nodes.ts:forLisReorder + tests/for-lis-scratch-release.test.tsx. Rule: any reusable scratch buffer that stores OBJECT references must be nulled at the end of each pass; ask "what pins this slot when the workload shrinks?"


Introspection registries holding STRONG refs to DOM captured once at setup (Class H — the devtools _components instance, 2026-07)

the dev-mode devtools component registry stored el: parent.firstElementChild (captured ONCE at mount) as a strong property in a module-level Map, cleaned up only by unregisterComponent at UNMOUNT. But a component's DOM can be REPLACED by a reactive re-render while the component stays mounted — the entry then pins the detached ORIGINAL subtree for the component's entire lifetime, with no cleanup event ever firing. Found via a real downstream heap snapshot (retainer chain _components → entry → el → detached <div class="metric-card"> — a dashboard whose cards re-render on data refresh). Fix shape: back the field with a WeakRef + getter (get el() { return elRef?.deref() ?? null }) — reads are unchanged for live elements, replaced DOM becomes GC-eligible immediately, and the public entry.el API shape survives. Rule: an introspection/diagnostic registry (devtools, perf counters, tracing) must never be a GC root for DOM or app objects — its lifecycle-event cleanup only covers the object's END of life, not mid-life REPLACEMENT of what it points at. Hold via WeakRef; the "cleanup exists" answer is insufficient when the pointed-at value can be swapped without a lifecycle event. Detection: heap-snapshot retainer analysis, not the leak-sweep (the count is small and constant-slope per re-render burst). GC-observable lock: packages/core/runtime-dom/src/tests/devtools-el-weakref.test.tsx (bisect-verified: strong ref → expected <div class="metric-card"> to be undefined). Reference: packages/core/runtime-dom/src/devtools.ts:registerComponent.


Position-based pop for stack frames unmounted out-of-order (ERROR-BOUNDARY STACK)

same bug class as the context-stack one below, different shared array. ErrorBoundary pushed its error handler onto a module-level _errorBoundaryStack and registered onUnmount(() => popErrorBoundary()) where popErrorBoundary did stack.pop(). Sibling boundaries unmount in renderer-driven order — keyed <For> removing a non-last item, <Show> flipping the FIRST of several, route nav unmounting an outer of nested routes — so a non-last sibling's onUnmount popped the LAST sibling's handler instead of its own. Survivor's handler gone from the stack; orphan's handler at the top. Subsequent throws in the survivor's children dispatched to the orphan (whose owning boundary is disposed → error.set(err) is a no-op) → error silently swallowed AND survivor's fallback never rendered. Fix: popErrorBoundary(handler) accepts the handler reference and removes by IDENTITY via lastIndexOf + splice. Each ErrorBoundary's onUnmount passes its OWN handler. Reference: packages/core/core/src/{component.ts:popErrorBoundary, error-boundary.ts:71}. The class generalizes: any time framework code does push(X) at setup + pop() at cleanup on a module-level array, ask "can X be removed in non-LIFO order?" If yes, switch to lastIndexOf + splice with the pushed reference.


[CLIENT-SUPERSEDED — applies to genuine module-level stacks] Position-based pop for stack frames that may be pushed by reactive boundaries

popContext() does stack.pop() — pops the LAST frame. Pyreon client context no longer uses a global stack (owner-based: provide() writes onto the component's EffectScope owner, released when the scope is disposed — no orphan-frame growth, no position-pop hazard), so this no longer applies to client provide()/useContext(). It still applies to any genuine shared module-level stack: the SSR request-scoped stack (consumed via pushContext/popContext/removeContextFrame, isolated per request) and the *-compat layers' own stack-based provide/inject. For those, if you push onto the stack and a reactive boundary's effect can run during your lifetime, never use a position-based pop for cleanup — capture the pushed frame reference at push time and register cleanup as () => stack.splice(stack.lastIndexOf(frame), 1) (identity-based removal via removeContextFrame). The historical client-side amplification (hundreds of thousands of orphan frames under nested reactive boundaries × toggles) is the bug the owner model structurally eliminated. Reference: packages/core/core/src/context.ts:removeContextFrame; the ctx-stack-growth-repro.test.tsx regression now asserts the owner model keeps the (compat/SSR) stack bounded.


A per-view dispose() destroying a SHARED, lazily-cached resource

when a reactive primitive is a view over a resource that is keyed/cached and shared by other consumers (a WeakMap<Y.Doc, Awareness> shared by N transports + N views; a module-level connection pool; a ref-counted singleton), its dispose() must tear down ONLY what that view added (its own listener/subscription), NOT the shared resource. Tearing down the shared resource on one view's dispose strands every other holder — and because Pyreon auto-calls dispose via onCleanup on component unmount, a SINGLE component unmounting silently kills the resource for the whole app. Fix shape: ownership lives with the thing that CREATED/keys the resource (the doc owns its awareness → YjsCrdtDoc.destroy()destroyDocAwareness), not with any view; the view's dispose is listener-detach only; "announce departure"-style side effects belong on the transport/connection layer, not the view. Real-world hit: @pyreon/sync's syncedAwareness().dispose() originally did aw.destroy() + removeAwarenessStates + docAwareness.delete — so disposing one presence view (or one component unmounting) destroyed the doc-shared Awareness, killing the transport + every sibling view. Fixed to listener-only; the doc owns teardown. Bisect-locked by the multi-view + doc.destroy() specs in packages/fundamentals/sync/src/tests/awareness.test.ts. The smell to watch for: a dispose() that calls a .destroy() / .delete(key) on something it get-or-peek'd from a shared cache rather than something it allocated itself.


A create-if-missing CRDT seed written BEFORE the transport syncs — a pre-sync default loses the clientId tie-break to a peer's real value (silent lost update)

@pyreon/sync's syncedSignal({ key, initial }) used to write map.set(key, initial) the moment the key was locally ABSENT — at construction, BEFORE the transport completed its first sync. Two fresh peers both seed their default, and a seed still causally CONCURRENT with another peer's real .set() resolves the Y.Map tie by clientId — and Yjs assigns clientIDs RANDOMLY — so a fresh peer's default can PERMANENTLY clobber a real value (the "two devices open, one types, the other's default wipes it" bug). This masqueraded for months as a CI "timeout flake" on ws-relay.test.ts (escalated 8→15→20→30s) because a lost update looks like "nothing to wait for" — bimodal (passes fast OR burns the whole budget), worse under CI load (contention widens the concurrent window). Fix shape: DEFER the seed until first sync when a transport is attached — seed IMMEDIATELY only when provably alone (no transport) or already synced; else wait for the transport's synced and RE-CHECK map.has(key), seeding only if STILL absent (a peer value that arrived during sync was already applied by the observer → skip). initial still shows as the OPTIMISTIC local value (base signal), but the CRDT WRITE is what defers. The deferral must be CANCELABLE on dispose (no write after teardown). The doc↔transport seam is doc-owned + engine-neutral (crdt/doc-sync.tsregisterDocTransport/docHasUnsyncedTransport/whenDocSynced, keyed on the CrdtDoc object, mirroring the awareness registry keyed on doc.yDoc): the transport registers a { synced, onSynced } state when it attaches; syncedSignal (which only holds a CrdtDoc) consults the doc, never the transport. WebSocketTransport gained a reactive synced signal + whenSynced() (the y-websocket provider.on('synced') convention — flips true on the first inbound update after open, i.e. the relay's reply to our state vector, which the relay ALWAYS sends even for an empty room, so it never hangs). General rule: a create-if-missing write into a DISTRIBUTED/CRDT store must not fire before the store has synced when a sync transport is attached — a pre-sync default is causally concurrent with every un-received peer op, and concurrency in a LWW register is decided by a RANDOM tie-break, so the default can win over real data. Residual (documented, inherent): two FRESH peers seeding an EMPTY room with DIFFERENT defaults for the same key still tie-break — gate app-level defaults behind await transport.whenSynced(). Detection lesson: the load-dependent ws-relay flake does NOT reproduce locally (fast loopback misses the concurrent window); the deterministic proof is the CRDT-level unit repro (seed-deferral.test.ts) asserting convergence on BOTH clientId orderings — a coin-flip bug needs the semantic bisect, not the integration test. Reference: packages/fundamentals/sync/src/{synced-signal.ts,crdt/doc-sync.ts,crdt/yjs-ws-transport.ts}; bisect-verified (revert deferral → {a:'',b:''} clobber on ordering (1,2)). Issue #2380.


Nulling WebSocket handlers before close()

ws.onmessage = null; ws.close() — a queued message arriving between null-assignment and close fires a null handler and crashes. Always close() FIRST, then null the handlers.


intentionalClose reset on reactive dependency change

If a user explicitly calls close() on a WebSocket subscription and a reactive dependency (URL, enabled) changes, don't silently override intentionalClose and reconnect. Respect the user's explicit close unless enabled was explicitly provided and transitions to true.


Silent plugin/init error swallowing

catch (_err) { /* silent */ } in plugin runners or async initialization hides bugs. Always log in __DEV__ mode and call user-provided onError callbacks. Reference: store/src/index.ts (plugins), storage/src/indexed-db.ts (IndexedDB init).


An async _mount that lazy-loads a heavy engine, with dispose() gated only on view.peek()

(the editor-adapter async-mount-lifecycle class — @pyreon/rich-text + @pyreon/code both shipped instances). A WYSIWYG / code-editor adapter creates its underlying engine (new Editor() / new EditorView()) only AFTER an await import('@tiptap/*') / await loadLanguage(...) — so there's a window where the editor is "mounting" but view is still null. Three bugs hide in that window, all confirmed in a real browser and all invisible to happy-dom / mount-only tests: (1) dispose-during-pending-mount leaks the enginedispose() reads view.peek() (null mid-load), no-ops; the await then resolves and view.set(editor) creates a live ProseMirror/CodeMirror view + contenteditable DOM that nothing will ever tear down (a fast navigate-away while the lazy chunk loads is the real trigger). (2) mount failure is an unhandled promise rejection — the mount component does void instance._mount(el) with no .catch, so a broken extension set, a throwing extension, or a failed chunk import surfaces only as a cryptic uncaught rejection with no framework context, and the editor silently never mounts (all computeds stuck at their fallbacks). (3) re-mount reverts to the config-time content — if _mount seeds the engine from a captured pendingContent (the initial config), disposing + re-mounting the SAME instance (the documented user-owned lifecycle) loses every edit, because the live document lives in a separate signal. Fix shape (all three, in _mount/dispose): a mountToken generation counter — dispose() (and any newer _mount) bumps it; _mount captures const token = ++mountToken BEFORE its awaits and bails (if (token !== mountToken) return, destroying the engine if already constructed) after each await, closing the leak + concurrent-mount race; wrap the body in try/catch routing to a config onError (and a [Pyreon]-prefixed dev console.error when absent) so failures never escape as unhandled rejections; track hasMountedOnce and seed a re-mount from the LIVE document signal (baseJson.peek() / value.peek()), not the stale config content. Both packages are now fixed (@pyreon/rich-text #1914, @pyreon/code #1916): rich-text had all three bugs; @pyreon/code had (1) + (2) only — it already seeds from value.peek() so it never had (3) — and its <DiffEditor> had the SAME leak by a different route (an unmount during the async grammar load left onUnmount to run with mergeView still null, so the resolving await then built a leaked MergeView), guarded with an unmounted flag set in onUnmount and checked after the await. Note the component-managed case (DiffEditor) uses an unmounted boolean rather than a mountToken, since it's a per-render ref, not a re-mountable instance. Reference: packages/fundamentals/rich-text/src/editor.ts:_mount + dispose; packages/fundamentals/code/src/editor.ts:mount + dispose + components/diff-editor.tsx. Regression-locked by bisect-verified async-mount-lifecycle specs in BOTH packages: rich-text.browser.test.tsx (re-mount preserves edits / dispose-during-mount no leak / mount failure → onError) and code.browser.test.tsx (createEditor dispose-during-mount no leak + mount failure → onError, DiffEditor unmount-during-load no leak).


Untracked requestAnimationFrame loops

requestAnimationFrame(animateFrame) inside animation functions without storing the frame ID leaks frames when the function is called again or the instance is disposed mid-animation. Always store the ID (_frameId = requestAnimationFrame(fn)), cancel previous (cancelAnimationFrame(_frameId)) before starting new, and cancel in dispose(). Reference: flow/src/flow.ts_layoutFrameId / _viewportFrameId.


Bare requestAnimationFrame / cancelAnimationFrame in animation paths

even with frame-ID tracking, if an async function (async layout() awaiting await computeLayout(...)) reaches its rAF call site AFTER vitest tears down the test environment, the now-undefined global throws ReferenceError: requestAnimationFrame is not defined. All 324 tests pass, vitest reports unhandled errors, the job exits 1 — confusing. Wrap rAF/cAF in defensive helpers that no-op when the global isn't function-typed: const _raf = (cb) => typeof requestAnimationFrame === 'function' ? requestAnimationFrame(cb) : 0. Reference: flow/src/flow.ts_raf / _caf. Same pattern applies to any animation path that completes asynchronously and could land in stripped-down envs (SSR, post-teardown, web workers).


Date.now() + Math.random() for unique IDs

Under rapid operations (paste, clone), Date.now() returns the same value within a millisecond and Math.random().toString(36).slice(2, 6) has only ~1.67M combinations — collision probability is non-trivial. Use a monotonic counter instead. Reference: flow/src/flow.ts_pasteCounter.

Detected by: date-math-random-id — surfaced by @pyreon/lint / pyreon doctor / MCP validate.


Raw addEventListener / removeEventListener in component or hook bodies

Bypasses Pyreon's lifecycle cleanup — listeners leak on unmount. Use useEventListener from @pyreon/hooks which registers the cleanup automatically. The detector only flags bare window / document / common DOM identifiers (el, element, node, target) so framework-host chains like view.dom.ownerDocument.addEventListener(...) are left alone.

Detected by: raw-add-event-listener · raw-remove-event-listener — surfaced by @pyreon/lint / pyreon doctor / MCP validate.


effect() doing imperative work at component setup

[pyreon/no-imperative-effect-on-create]: effect(() => fetch(...)), effect(() => document.addEventListener(...)), effect(() => setTimeout(...)) allocate per-instance and run synchronously during component setup — that's how PR #268's bokisch.com 20s render bug accumulated under 150 component instances. effect() is for pure reactive subscriptions (signal reads + signal writes); imperative work (DOM, IO, scheduling) belongs in onMount(() => { ... }). The lint rule narrowly flags fetch / setTimeout / setInterval / requestAnimationFrame / requestIdleCallback / queueMicrotask global calls and document.X / window.X / localStorage.X / sessionStorage.X member access inside effect bodies. Pure effect(() => sum.set(a() + b())) and effect(() => console.log(count())) are NOT flagged. Foundation hooks (@pyreon/hooks, @pyreon/rx) are exempted via exemptPaths because they're the layer that wraps timers/listeners FOR users — flagging them would defeat the abstraction.


Statically importing a heavy module used ONLY in an event handler / lifecycle callback

[pyreon/no-heavy-import-only-in-handler]: import { renderChart } from '@pyreon/charts' at module top, with renderChart referenced exclusively inside onClick={() => renderChart(el)} (or an onMount/onUnmount/onCleanup callback) forces the heavy @pyreon/charts chunk into the INITIAL bundle even though nothing touches it until the user interacts. The fix is a dynamic await import('@pyreon/charts') inside the handler — the chunk then stays out of the eager graph and loads on demand. Distinct from pyreon/no-eager-import (info, fires on EVERY heavy static import including ones genuinely needed at render): this rule is the PRECISE, actionable counterpart — it fires only when EVERY reference to the binding is provably inside a deferred scope, so the recommended fix is unambiguous and there is no "but I need it at render" false positive. Conservative by construction: a single eager reference (JSX element <Chart/>, a module-eval const x = renderChart, a plain helper called at render) suppresses the report entirely — a false negative is acceptable, a false positive (telling someone to defer an import they need eagerly) is not. Heavy set defaults to the 4 documented lazy-loaded Pyreon packages (@pyreon/charts|code|flow|document) and is extensible via the heavyModules: string[] rule option. Origin: distilled from the Tier-2 resumability spike's L3 heavy-import classification — the one part of that (otherwise shelved) analysis with zero false-positive risk. effect / renderEffect are deliberately NOT in the deferred-scope set: their callbacks run synchronously during component setup (the no-imperative-effect-on-create shape), so a heavy module used in an effect body is a render-time dependency, not a deferrable one — recommending a dynamic import there is wrong. This exclusion was driven by the real-corpus e2e, not foreseen in the synthetic specs: examples/app-showcase/src/sections/invoice/LivePreview.tsx calls @pyreon/document's render inside an effect (a legit reactive render); the initial deferred-set including effect false-positived there. General lesson (reinforces the M3.B rule below): a new lint rule MUST be run against the real example corpus before merge — synthetic FIRES/DOES-NOT-FIRE specs only cover the shapes the author thought of; the real tree surfaces the scope semantics the author got subtly wrong (here: "deferred lexical scope" ≠ "deferred execution"). Reference: packages/tools/lint/src/rules/performance/no-heavy-import-only-in-handler.ts; bisect-verified — disabling the eager-guard collapses it to no-eager-import (the 4 conservative DOES-NOT-FIRE specs fail); validated zero-false-positive across all 577 example .ts(x) files via the workspace programmatic API (NOT bunx pyreon-lint, which resolves to a non-existent npm package — use lintFile from the workspace src or bun run --filter='@pyreon/lint').


Closure-captured parent in a reactive mount loop becomes stale after a sibling reconciler moves the markers

any framework primitive that (a) accepts parent as a setup arg, (b) inserts a marker into that parent, and (c) calls parent.insertBefore(...) from inside an effect re-run is structurally unsafe under Pyreon's mountFor frag-then-move pattern. mountFor builds its children into a DocumentFragment and then commits via liveParent.insertBefore(frag, tailMarker) — the move carries the marker and all sibling DOM along, but the inner mount's CLOSURE still holds the original (now-empty) fragment as parent. The next signal-driven re-run calls insertBefore(node, marker) against the stale fragment → throws NotFoundError: Failed to execute 'insertBefore' on 'Node': The node before which the new node is to be inserted is not a child of this node. The throw lands in Pyreon's unhandled-effect-error path → console.error + complete loss of the subtree's children from the DOM. Two real instances fixed in this bug class: PR #776 mountReactive (For-of-Show batched-toggle — captured parent stale after mountFor frag-then-move), PR #783 mountKeyedList (For-of-direct-keyed-array — same shape, three call sites: mountNewEntries, mountVNode, keyedListReorder → applyKeyedMoves → moveEntryBefore). Fix shape (both PRs): read marker.parentNode (or tailMarker.parentNode for keyed-list shapes) at each effect run, threading the resulting liveParent through every helper that does insertBefore. Falls back to closure-captured parent only when marker is detached (cleanup edge case) — the cleanup path was already consistent (marker.parentNode?.removeChild(marker)), only the mount/reorder paths used the stale captured parent. General rule for framework code: any mount loop running inside an effect that accepts parent as a setup arg should compute the live parent from a marker at each re-run — parent is captured at setup and the DOM moves; markers are part of the moved DOM and their parentNode reflects the current live parent. Sibling primitives audited safe (2026-Q2): mountFor itself uses startMarker.parentNode at the top of its effect; KeepAlive / TransitionGroup / Transition use containerRef.current (live DOM-element refs that move with the surrounding tree, never become stale); template.ts's _bindText/_bindDirect write to text-node .data only (no parent.insertBefore). Reproducer: bun run perf:leak-sweep --app perf-dashboard --journeys domConditionalToggle-1000 (the For-of-Show shape; #783's keyed-list shape is reproduced by the regression test at packages/core/runtime-dom/src/tests/keyed-array-in-for-batched-toggle.browser.test.ts — requires For children to return a function directly so mountKeyedList lands with frag-as-parent, not the more common <div>-wrapped shape which routes through the <div> and isolates from the frag-move). Discovery chain: #770 leak-audit harness → #772 leak-sweep multi-journey driver → #774 it.fails CONTRACT lock → #776 mountReactive fix → #783 mountKeyedList sibling fix → #779 nightly leak-sweep CI gate.


Reactive-render entry points missing runUntracked around child mounts

any framework primitive whose effect body mounts children (mountFor, mountKeyedList, KeepAlive, TransitionGroup, custom equivalents) MUST wrap the child mount work in runUntracked(() => ...) — same shape mountReactive already does. Without this, signal reads during a child's setup (useQuery's new QueryObserver(client, options()) reading the queryKey signal at construction time, useTheme reads, any signal() invocation in a component body) leak their subscription up to the parent effect's activeEffect. When the leaked signal flips, the PARENT effect re-runs → runCleanup() disposes ALL inner effects (the children's per-component effects), and the parent's keyed-update path skips re-mount on unchanged keys → the children's reactivity is gone forever. Real-world shape: PR #490's queryReactiveKey-1000 journey saw 0 setOptions runs across 10 flips of a shared reactive query key — signalWrite: 1015 confirmed the writes happened, query.setOptions: 100 confirmed only the initial-mount runs fired, the 1000 expected re-runs never came. Fix lives at packages/core/runtime-dom/src/nodes.ts (mountFor, mountKeyedList), keep-alive.ts, and transition-group.ts. Bisect-verified: reverted the runUntracked wrap → <For>-shaped regression test failed with expected 100 to be 0 (all 100 inner effects didn't fire after first flip); restored → all 11 expected runs per effect. Reference: packages/core/runtime-dom/src/tests/fanout-repro.test.tsx.


Mount-order-vs-logical-position: a node mounted at a FIXED physical anchor (the tail) but recorded with its TARGET LOGICAL index, then fed to a position-based reorder that trusts that recorded position

mountFor's general (LIS) reconciler decides which entries stay vs. move by reading each ForEntry.pos as its CURRENT (pre-reorder) DOM position and computing the Longest Increasing Subsequence over [entry.pos for entry in newKeys-order]. New entries are physically mounted before tailMarker (at the tail), but mountNewForEntries recorded their pos as the NEW logical index i — so a new row whose logical index sat BETWEEN two survivors' stale pos values ([1,2,3,4] → [1,5,3]: survivors 1@0, 3@2; new 5 recorded @1) made the LIS sequence look strictly increasing → the new row was judged "already in order" and never moved off the tail → rendered [1,3,5]. Triggered ONLY on the LIS path — taken when trySmallKReorder bails, dominantly on a LENGTH change (an add + a remove together), so n !== currentKeys.length. The small-k path was UNAFFECTED because it places new entries via their surviving next-sibling's anchor (smallKPlace), never reading pos. The bug was ALSO position-dependent: many add-into-vacated shapes dodged it by pos collision (a head insert whose pos=0 collided with a survivor's pos=0, letting the LIS tiebreak evict it) — only a strictly-straddling index stranded the row, which is why it survived every existing swap/reverse/append/prepend test. Fix (correctness AND fast-path-preserving): a new entry that has a SURVIVOR after it in newKeys (prepend / middle insert) MUST move off the tail, so it gets a SENTINEL pos (-1) that computeForLis SKIPS — it is never an LIS "stay" member, and applyForMoves threads it in before its logical successor (Solid/ivi design: new nodes never participate in the LIS). A new entry in the TRAILING all-new run (append — no survivor after it) keeps a strictly-increasing pos ABOVE every survivor (currentKeys.length + added; survivors ∈ [0, currentKeys.length) by the post-update invariant that fresh/replace/reorder all set pos = logical index) so the LIS extends it as a STAY — append does ZERO moves. The naive first cut — pos = currentKeys.length + added for EVERY new entry — is correct but silently REGRESSED the PREPEND fast path (the survivor block, now processed after the high new block, fell out of the tier-2 known-slot path into ~10000 binary-search probes; @pyreon/perf-harness's big-list prepend … zero lisOps counter caught it — a <For> reconciler change ripples into the perf-harness counter locks, so run BOTH @pyreon/runtime-dom AND @pyreon/perf-harness tests). The sentinel split keeps prepend at 0 probes (survivors are a monotone run the LIS extends; every new row is a skipped sentinel) and leaves pure shuffles/reversals BYTE-IDENTICAL (no new keys → no sentinels → the if (v < 0) continue guard never fires). General rule: any reconciler that mounts new nodes at a fixed anchor but feeds a POSITION-BASED reorder (LIS, insertion sort, cursor walk) must (a) exclude a node that has to MOVE from the reorder's "already-ordered" candidate set entirely (a lie about its position produces a "no move needed" false negative), and (b) treat only nodes already at their final position (the trailing append run) as stays — and must NOT pay the correctness fix with a fast-path regression, which a downstream perf-counter lock will surface. Same "value fed to a downstream algorithm must not lie about what the algorithm assumes" family as the rocketstyle _rsMemo cache-key class. Detection: real-mount DOM-order assertion on add-into-a-vacated-MIDDLE-slot with a length change ([1,2,3,4] → [1,5,3] → assert <1><5><3>); a happy-dom mount() + items.set() + container.textContent test catches it (this is NOT a compiler-template shape — the reconciler bug reproduces through plain h(For, …)). Bisect-verified: reverting mountNewForEntries's pos to the logical index i fails the middle/head/multi-add specs with the new row(s) stranded at the tail; restored → all pass, and big-list prepend/append stay at 0 lisOps. Reference: packages/core/runtime-dom/src/nodes.ts:mountNewForEntries (sentinel/trailing split) + computeForLis (v < 0 skip) + tests/for-add-into-vacated-slot.test.tsx + @pyreon/perf-harness/src/tests/big-list.test.ts.


Re-entrant signal write inside the same effect's batch flush

when an effect's run body writes to a signal it's currently subscribed to (or any signal whose ONLY subscriber is the same run), historically the batch system silently dropped the re-fire — signal.set wrapped the notify chain in batch(), the flush iterated subscribers from a Set, and JS Set iteration + Set.add idempotency meant re-enqueuing an already-visited entry was a no-op. Fixed by the two-tier flush in packages/core/reactivity/src/batch.ts: tier 1 drains computed.recompute callbacks (cascading-iteration with within-pass Set dedup), tier 2 drains effect.run callbacks in multi-pass mode (within-pass dedup preserved by Set.add idempotency on entries not yet visited; cross-pass re-fire enabled by routing already-visited entries to _nextEffectPass). The two-tier split is what computed.ts:_markRecompute(recompute) registers — the WeakSet-based brand routes computed callbacks to tier 1 so all derived values settle before any effect runs (prevents the deep-cascade stale-read shape this code path used to hide). MAX_PASSES caps tier-2 at 32 to prevent pathological infinite re-enqueues; converging patterns terminate after 1-2 passes. Reference: packages/core/core/src/error-boundary.ts:handler calls error.set(err) synchronously and the boundary's effect re-fires on the next pass (mounting the fallback). No queueMicrotask defer, no synchronous handling flag — both were workarounds before the structural fix and have since been removed.


Lifecycle & Cleanup Mistakes