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 static child mounted into a freshly-cloned template element with a per-node DOM remover tears the clone down one node at a time — the ownership test is "does this node leave with its parent?", and a compiled slot's static value does

(the dispose-500 board instance, 2026-09). mountChild hands every element it mounts at _elementDepth === 0 its own remover, which is correct for a node whose parent is LIVE and outlives it. A compiled <div>{children}</div> lowers to _mountSlot(children, __root, <!>) and passed a static row array straight to mountChild at depth 0, so a 500-row list's dispose ran 500 removeChild calls (64% of the profile) before the container — already holding every one of them — was removed itself. The clone's own children never had this: _tpl mounts them under _elementDepth++ and their cleanups are effect-only. mountChildAsUnit gives a static slot value (and _mountChild's non-accessor absorbed component) that same contract. The rule has two halves and the second is the guard: a static value is part of the clone and leaves with it, so effect-only cleanup is correct — but an ACCESSOR slot is a reactive boundary that re-renders INTO the live clone, and it must keep the full remover or its previous render is stranded on every flip (the mountReactive stranded-nodes class above). Branch on typeof children === 'function', never on the parent's depth. Detection lesson: the reactivity-side lever measured a 3× on-CPU win on the ladder and the board did not move at all, because no ladder arm had the board's exact shape — add the arm before concluding the residual is "the framework's floor". Reference: packages/core/runtime-dom/src/mount.ts:mountChildAsUnit + template.ts:_mountSlot; locked by tests/slot-children-unit-teardown.test.tsx (bisect-verified: revert → 500 individual removals counted, accessor-slot flip still clears its range).


A binding that ADOPTS a node it did not create must still REMOVE it on dispose — "who created it" is the wrong ownership test, "whose live parent is it in" is the right one

bindPolymorphicText disposes its effect and, in text mode, leaves the bound text node in the DOM (its swap core removes only the marker + subtree of sub mode). Every hydration call site binds a node in an ALREADY-LIVE parent, so the binding owns it — the same contract mountChild's dispatcher applies at _elementDepth === 0, where it DOES remove. The mismatch was invisible for as long as every reactive accessor re-mounted over a full range swap (the parent deleted the whole range regardless, so a missing child remover could never be observed) and became live the moment accessors began ADOPTING their range: a NESTED accessor's adopted text survived its parent's re-emission, rendering x<b>…</b> where a client mount produced <b>…</b>. General rule: a cleanup's job is decided by whether anything ELSE will remove the node, not by whether this code created it — and a dormant ownership bug becomes reachable the moment an ancestor stops doing wholesale teardown, so re-audit child cleanups whenever you make a parent adopt instead of rebuild. Fixed with a bindOwnedText wrapper at the hydrate sites only (the <For> row-plan caller binds a descendant of a row element removed as a unit, so it correctly keeps the non-removing form). Bisect-verified: reverting the 4 call sites reproduces the parity fuzzer's O3 post-flip divergence on seeds 12/16/23/25/55 plus expected '<main>x<b>replaced</b></main>' to be '<main><b>replaced</b></main>'. Reference: packages/core/runtime-dom/src/hydrate.ts:bindOwnedText + tests/hydrate-accessor-adoption.test.tsx.


An effect-wrapper (watch) that stores its per-run cleanup in a CLOSURE the effect doesn't own orphans that cleanup on SCOPE disposal (the watch instance, 2026-08 — Class I/D)

@pyreon/reactivity's watch(source, cb) ran effect(() => { … const result = cb(...); if (typeof result === 'function') cleanupFn = result }) and kept the returned per-run cleanup in a module-local let cleanupFn. The effect body returned NOTHING, so the effect's own runCleanup (which fires on re-run AND on dispose()) never saw it — cleanupFn ran ONLY (a) at the start of the next re-run, or (b) when the caller invoked the returned stop(). A consumer that DISCARDS stop() and relies on the OWNING SCOPE disposing the effect (the dominant shape — watch called in a component setup, unmount disposes the scope) therefore ORPHANED the cleanup whenever the scope died between re-runs. Real hit: @pyreon/kinetic's useAnimationEnd (TransitionItem/Collapse/Transition) added transitionend/animationend listeners + a setTimeout(done, timeout) (default 5000ms) in the watch callback and discarded the disposer; unmounting a component mid-enter-animation (route change, <TransitionGroup> teardown) left the 5s timer + both listeners pinning the detached wrapper subtree AND the onEnd closure (which holds the disposed component's signals) — self-healing (the timer fires done() which clears itself), so BOUNDED not monotonic, which is exactly why the heap-slope leak-sweep is blind to it. Every watch consumer that returns a cleanup had the same latent orphan (form / hooks useFocusTrap/useFocusReturn/useInertOthers / ModalBase / validate). Fix (fundamentally-correct, at the root not the consumer): register the per-run cleanup on the EFFECT via onCleanup(result) inside the effect body, instead of a closure var. The effect's runCleanup then fires it before each re-run (same as before) AND on dispose() (the fix) — so scope disposal runs it. stop() collapses to () => e.dispose(). Behaviour-preserving otherwise: the pre-existing "cleanup runs before each re-run" + "immediate cleanup runs on next change" specs stay green, and the cleanup now runs UNTRACKED (runCleanup is outside the new run's tracking) which is strictly more correct (a teardown shouldn't create subscriptions). General rule: any effect-wrapper (watch/watchEffect/an autorun helper) that lets its callback RETURN a per-run cleanup must hand that cleanup to the effect (via onCleanup/the effect's own return) so the effect OWNS it — a cleanup parked in a closure only runs on the wrapper's explicit paths (re-run, manual stop) and is orphaned by the scope-disposal path every real consumer actually relies on. Bisect-verified in packages/core/reactivity/src/tests/watch.test.ts ("per-run cleanup runs when the OWNING scope disposes"): revert to the closure form → expected +0 to be 1 (cleanup orphaned); restore → 749/749 reactivity + kinetic 270 / form 376 / hooks 493 / validate 726 / ui-primitives 366 all green. Reference: packages/core/reactivity/src/watch.ts.


Two independent sources suspending the same timer through ONE shared flag — and the reflex fix (a depth counter) trades the bug for a worse one

(the @pyreon/toast pause instance, 2026-09; Class D). <Toaster> paused the auto-dismiss clock on mouseenter and on focusin, and resumed on mouseleave and focusout, all through a single _paused boolean. The two sources overlap in ordinary use — a keyboard user tabs into a toast, then the pointer sweeps across the stack and off it again — and the mouseleave cleared the FOCUS hold, so the toast dismissed itself out from under the reader with no error and nothing to bisect from. The fix is a set of holds keyed by SOURCE IDENTITY ('hover' | 'focus' | 'hidden'), released individually, with the clock restarting only when the set empties. A depth COUNTER is the obvious alternative and is strictly worse here, for a reason that generalises: a counter is only correct while every take is matched by exactly one release, and browsers do not guarantee that — removing a focused element (which an auto-dismiss does routinely) need not fire focusout, so the depth sticks above zero and every later toast is frozen for the life of the page. A stranded 'focus' hold, by contrast, is cleared by the next focusout. Same reasoning as the Class A "remove by identity, not position" fix, applied to a suspension registry rather than a stack. General rule: when N independent sources can suspend one shared thing, the suspension must be keyed by WHICH source holds it; and prefer identity over a count wherever the release is not guaranteed to arrive. The same PR added the third source and it is worth stating on its own: a countdown that exists to buy the user ATTENTION must not run while the tab is hidden — a 4s toast raised just before a tab switch is gone before anyone looks, which is indistinguishable from never having fired (sonner and react-hot-toast both suspend on visibilitychange). Two details make that wiring safe: the handler is idempotent at the SOURCE (it compares against its own last-known state, so a duplicate visibilitychange cannot take a second hold), and it reads document.visibilityState at SETUP as well, so a Toaster mounted into an already-hidden tab starts suspended instead of waiting for a transition that already happened; the onCleanup releases a held hold rather than stranding it. Detection note: document.visibilityState is read-only in a real browser, so the spec redefines the accessor and dispatches the event by hand — and must READ IT BACK before asserting, or a silently-failed redefinition makes every assertion vacuous. Reference: packages/fundamentals/toast/src/toast.ts (PauseSource / _pauseHolds) + toaster.tsx (syncVisibility); locked by tests/pause-sources.test.ts + the visibility block in tests/toaster.browser.test.tsx, bisect-verified against THREE distinct broken builds (the shipped single flag → 2 node + 1 browser spec fail; a depth counter → 2 node specs fail; the wiring removed → 1 browser spec fails).


Writing the differential test is what finds the bug — the shipped implementation was the SUBJECT, not the oracle

(the LTTB decimation instance, 2026-09). @pyreon/charts' lttb was being rewritten for the native subset (integer bucket edges, because a Double cannot bound a Swift/Kotlin loop or subscript an array). The rewrite is a change to which points a 100k-row chart draws, so the plan was a differential against the shipped implementation kept verbatim as the oracle — and it disagreed. Chasing the disagreement found the oracle was wrong: its buckets were indexed one place to the right of the canonical formulation, so the first interior bucket was never considered (a spike near the start of a series could not be selected however prominent) and the last bucket spanned the empty range [n-1, n-1), leaving best at its initial n - 1 and emitting the pinned final row TWICE — measured at 3,608 of 3,781 (size, threshold) pairs, i.e. maxPoints={N} drew N - 1 distinct rows. The same shift made the third triangle vertex the centroid of the bucket being selected FROM rather than the next one, which is not the LTTB criterion; the comment beside it said "next bucket" while the indices said otherwise. Rules: (a) when a differential against a trusted reference disagrees, the reference is a suspect too — resolve which side is right against the SPECIFICATION, not against whichever side is older; (b) a comment that describes the intent (// Average of the NEXT bucket) beside indices that do something else is the highest-signal smell in numeric code, because both halves look reviewed; (c) an algorithm whose output is a SELECTION rather than a value needs invariant assertions — strictly increasing, exactly threshold entries, endpoints pinned — since "the chart still looks right" cannot distinguish a duplicated final vertex from a correct one. The whole suite passed against the duplicate for the life of the feature. Sub-lesson on the subset rewrite that started it: Math.floor(i * (span / count)) is NOT Math.floor(i * span / count) (float span/count can fall a hair short and floor one row early, in 0.066% of edge computations), and the fix — advancing edges by integer accumulation — is exact AND crossing, so the correctness and the portability had the same answer. Reference: packages/fundamentals/charts/src/engine/decimate-values.ts; locked by decimate-parity.test.ts, which keeps the shipped implementation verbatim as the SUBJECT of two specs that characterise what it got wrong.


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.


A native @Observable collection is ONE tracked property, and a per-element @Observable read costs ~2.6 µs — boxes are NOTIFICATION cells, never the engine's storage

(the PyreonFlowState port, 2026-09). v1 stored nodes: [PyreonFlowNode] on the @Observable class, so a view reading nodes[i].position was invalidated by ANY node's move — measured 1000/1000 trackers fired per updateNodePosition at N = 1,000, i.e. O(N) view re-evaluation per drag frame where the web engine's per-id equality computeds give O(1 + deg). The obvious fix — one @Observable box per node, nodes derived by walking them — restores per-node invalidation and then makes every INTERNAL loop 100× slower: fitView went 272 µs → 26.6 ms at N = 10,000, because each box.node read goes through the ObservationRegistrar (~2.6 µs), and engine code was now paying it once per node. Rule: keep the engine's own truth in @ObservationIgnored plain storage keyed by id (an O(1), non-generic-key hash; the v1 first { $0.id == id } scan also paid a 15–33× unspecialized-generic penalty in the Debug/simulator builds every device gate runs), write each change to the store AND that node's box, and let ONE observable version counter be what a whole-collection reader subscribes to; engine internals never read boxes. Compose has the cheap form built in (mutableStateMapOf is per-key snapshot state), so the Kotlin twin needs no boxes — but its whole-list mutableStateOf(List) v1 allocated an N-reference list per pointer-move. Two measurement lessons from the same PR: a first cut's removeEdges hashed every edge id against a Set in a second pass and measured 10× slower than the in-place removeAll(where:) it replaced (collect the removed ids into a LOCAL inside the one in-place pass); and the Kotlin harness runs on a functional HashMap stub, so its numbers exclude snapshot overhead — say so wherever they are quoted. Locked by the observation-granularity spec in flow/native/tests/PyreonFlowStateTests.swift (bisect-verified: v1 storage fails it with got 1).


A per-key registry torn down one hashed Map.delete at a time (the createSelector.subscribe instance, 2026-08)

a registry keyed by row id, unsubscribed per row, costs N × (Map.get + Map.delete) on a list teardown — ~25ns per key in V8, and there is NO shrink/rehash storm to blame (measured flat at 10%/25%/50%/75%/90%/100% of keys deleted). It is simply that hashed deletes are not free and lists are long. In a real-Chromium profile of a 1000-row clear rows this was the single largest non-DOM item at ~23µs, against 0.9µs for tearing down the per-row SIGNAL binding beside it — so "the price of fine-grained subscriptions" was the wrong diagnosis by more than an order of magnitude; the price was a SECOND per-key registry duplicating what the reconciler's own key map already held. Fix shape: put the value behind a HOLDER (Map<K, {fn}>) so the disposer closes over the holder and unsubscribing is one field write touching no map, and drop the whole map in one clear() when the live count reaches 0. The holder also PRESERVES the identity guard the removed Map.get was providing (h.fn === updater still makes a stale or repeated dispose a no-op — a bare Map.delete(key) cannot, and would silently unsubscribe whoever re-took the key). Reclaim on INSERT, never during a teardown burst: a dead > live trigger on the dispose path is walked THROUGH by a teardown counting down to zero, so it rebuilds the map two or three times before the last row proves the rebuilds pointless — measured 10.3µs of a 1000-row clear, which is most of the win. Growth is the only thing that needs bounding, so amortise at insertion (the same shape createSelector's sweep() already used for its tracked channel). Cost: one small object per LIVE key (measured 148.8 → 180.8 B/key), fully reclaimed on teardown. Three detection lessons, each of which cost a wrong answer first. (1) A "ceiling probe" that no-ops the teardown LEAKS, and the leak contaminates the number — boundSubs grew to 400k entries and inflated the unrelated replaceChildren frame by 8.4µs, understating the very win being estimated. Probe by making the path CHEAP, not by deleting it. (2) V8 INLINES the disposer chain, so a caller's self-time includes its callees': 12.7µs attributed to the <For> effect body was inlined selector work, and only vanished when the selector work did. Read a profile's frames as a tree, not a ledger. (3) A GC test must watch the object the bug actually retains — WeakRefs on the UPDATERS could not see a disabled sweep at all (the holder nulls them either way), and passed against the broken build; only WeakRefs on the object KEYS discriminate. Reference: packages/core/reactivity/src/createSelector.ts + tests/createSelector-bound-holder.test.ts (bisect-verified in three directions: identity guard, insertion-time sweep, live-count accounting).


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.


[CORRECTED, 2026-08 — this entry previously said the OPPOSITE] Calling close() before nulling a socket's handlers

(lint: pyreon/no-close-before-handler-teardown): ws.close(); ws.onmessage = null leaves a real window open. close() only STARTS the closing handshake — the socket enters CLOSING, and a frame already buffered can still be delivered to a handler that is still attached, which then writes into the scope the teardown just disposed. Null the handlers FIRST, then close(): assigning null to an event-handler IDL attribute simply detaches it, so a later event has nothing to call. The prior version of this entry claimed the reverse — that nulling first makes a queued message "fire a null handler and crash" — which is not a real JavaScript behaviour (verified empirically: null the handler, dispatch the event, nothing runs and nothing throws). The false mechanism had been copied verbatim into @pyreon/query's use-subscription.ts as the justification for the wrong order, in both its connect-supersede and disconnect paths, while @pyreon/hooks' useWebSocket and @pyreon/query's use-sse independently did it correctly with a correct rationale — so the framework disagreed with itself and the catalog backed the wrong side. Applies to EventSource identically. The general lesson is the one this catalog states elsewhere and this entry failed to follow: verify the MECHANISM, not just the symptom — a plausible-sounding cause in a rule file propagates into code as a comment, and then into a lint rule, and each copy makes it harder to question.


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.


An "already running" flag read BEFORE the await that acquires the resource is not a guard (leak class F/D, three shipped instances, 2026-08)

the idiomatic single-flight hook checks if (active()) return, awaits a permission or device handle, then sets the flag — so two calls arriving DURING that await both pass the check and each acquire a resource, and the first is overwritten and orphaned with no reference left to release it. @pyreon/hooks shipped this three times: useWakeLock (a WakeLockSentinel held with nothing able to release it — the screen never sleeps again), useAudioRecorder (a live getUserMedia microphone stream left open, recording indicator on), and useDeviceMotion (its listener attached twice, so every consumer's handler ran per event per extra call). The window is the await itself, so it opens for exactly as long as the permission prompt is on screen — i.e. it is WIDEST for the user who hesitates, and invisible to any test that awaits between calls. Fix: the guard must be the in-flight PROMISE, not a post-settle flagif (starting) return starting (share it, so concurrent callers get one resource) — and after the await, re-check and release what you just acquired if a newer call or an unmount won the race (if (cancelled) { sentinel.release(); return }). A version counter is the equivalent shape where there is nothing to share. General rule: a flag that a function sets AFTER its own await cannot exclude a second entry during that await; the only things that can are a shared in-flight promise or a generation counter captured before it — and either way the post-await path must still ask whether it is still the winner. Note this is the acquisition sibling of the documented stale-resolution class: there the late write clobbers state, here the early one strands a real OS resource. Caught by pyreon/no-unguarded-async-signal-write, which had to learn both halves — an in-flight promise counts as a guard, and the guard may live one scope OUT from the async function that writes. RECURRED in the same three hooks one release later (2026-09): the shared in-flight promise landed, the post-await re-check did NOT — so a concurrent caller was handled and an UNMOUNT (or an explicit release()/stop()) during the prompt still stranded the sentinel / mic stream / listener, because the cleanup ran while the handle was still null and had nothing to release. The invalidator is not only "another caller": it is anything that can change the answer while the await is up — dispose, release, supersede. A generation counter bumped by every teardown, captured before the await and compared after, covers all three; the regression specs assert STATE after unmount (a released sentinel, stopped tracks, a reading that stays 0), not listener call counts, which balance against the broken code because the remove precedes the attach.


A settle-once flag does not cover settle-NEVER — and a comment that claims it does is the bug's best hiding place (the file-picker hole, 2026-09)

useCamera / useFilePicker / useImagePicker each appended a hidden <input type="file"> to document.body and removed it inside settle, guarded by a settled boolean whose comment read "a browser that fires NEITHER event must not leak the node or double-resolve". The flag covers "both". It cannot cover "neither": with no event settle never runs, so input.remove() never runs, and the document then retains the node, its two listeners and the resolve closure for the life of the page — once per pick, unbounded (leak class C), with the promise pending forever. The unreachable case is the DOCUMENTED one: cancel is the event that would have fired, and the same comments call it "not universal across older browsers"; an unmount while the OS sheet is open produces it on every engine. The missing piece was an OWNER — a pick starts from an event handler, where there is no reactive scope on the stack to register onCleanup against, so the cleanup has to be registered by the HOOK during component setup and settle whatever is still in flight. That bounds retention to the component's lifetime, which is what every other resource in the package already does. Two general rules: (a) when a guard's comment states an invariant, check that the guard actually implements it — three byte-identical copies each carried this one and none held it; (b) a resource acquired from an event handler cannot own its own cleanup, so the hook that hands out the acquirer must. Reference: packages/fundamentals/hooks/src/file-picker.ts (the three copies are now one helper); bisect-verified in hooks/src/tests/file-picker-unmount.test.ts (removing the onCleanup hangs all three unmount specs for the full 20s timeout, while the already-settled controls stay green).


A module-scoped store is the right scope for a BROWSER and a cross-request leak on a SERVER (useSecureStorage / useDatabase, 2026-09)

both hooks back their web arm with a module-level Map, under a comment reasoning correctly about the browser — "the secret store is app-wide, like the Keychain it mirrors", "module-scoped so reads and writes within one page agree". In a browser one process serves one user, so app-wide and page-wide are the same thing. On a server one process serves EVERYONE, and both hooks reached that map under SSR: useDatabase.writeCollection mirrored into it BEFORE its isServer early-return and readCollection read from it, while useSecureStorage had no server branch at all. Verified by running the modules with no document (the real SSR arm, not a mock): a record inserted by "request A" was returned to "request B", and a session token written by A was returned to B verbatim — a cross-request SECRET leak, unbounded for the process lifetime. General rule: before reusing a module-scoped cache on the server, ask what one PROCESS means there. "App-wide" and "page-wide" are the same scope in a browser and different scopes in a server, so a comment that justifies the map in browser terms has not justified it for SSR at all. The fix is an inert server arm rather than a per-call map: per-call removes the leak but makes two components in one render disagree, while inert is consistent for every caller AND carries nothing across a request — and it is honest, because a localStorage/Keychain mirror has nothing to mirror on a server. Detection lesson: the server arm carried /* v8 ignore */ comments claiming it could not be tested without mocking @pyreon/reactivity, "which the test-environment rules forbid" — while two sibling files in the SAME directory (useCamera.ssr.test.ts, secure-context-ssr.test.ts) test their server arms with exactly that per-file mock. A coverage-ignore whose justification contradicts the neighbouring files is a strong signal that the branch is unexamined, not untestable. Reference: packages/fundamentals/hooks/src/{useDatabase,useSecureStorage}.ts; bisect-verified in hooks/src/tests/server-store-isolation.ssr.test.ts (restoring either shared map fails all 4 specs, including the secret read-back).


A registration seam whose only possible caller is the APPLICATION AUTHOR is unisolated by default (configureStoreIsolation, 2026-09)

@pyreon/store's registry is a module-level Map with a setRegistryProvider seam, and @pyreon/runtime-server exposes configureStoreIsolation(setter) to swap in an AsyncLocalStorage-backed provider. Everything about it was documented — the README, the manifest and the generated docs all said "call once at startup or concurrent requests share one global store registry (cross-user SSR state bleed)". Nothing called it. The seam takes a SETTER as an argument for a reason: @pyreon/server and @pyreon/zero own the server and neither depends on @pyreon/store, so neither can wire it — which leaves the application author, reached only through a paragraph in a package they never import. Verified on the default path: two runWithRequestContext calls (exactly what two concurrent SSR renders are) and request B read request A's store value. General rule: when a seam's argument list proves that no framework layer can call it, "documented as opt-in" means "off in production". Ask who is STRUCTURALLY able to opt in — if the answer is only the end user, the default is the behaviour, and for a cross-user data property the default must be the safe one. The fix is the __PYREON_STYLER_COLLECT__ shape and the same reasoning as the collectStyles entry above: the package that owns the state PUBLISHES its setter on a globalThis seam at module load (server-gated, so the browser pays nothing), and the consumer picks it up at its own choke point — no import in either direction, automatic for every consumer, explicit call retained as the override. Two details are load-bearing: the pickup must be LAZY (per render, not at module init — the renderer may evaluate before the app first imports the store), and the publishing package must declare sideEffects for its registration file or a consumer's tree-shaker deletes the registration. Reference: packages/fundamentals/store/src/registry.ts (publish) + runtime-server/src/index.ts:tryAutoWireStoreIsolation (pick up); bisect-verified per half, in the package that owns it — neither package depends on the other, so neither can test the pair end-to-end (store/src/tests/registry-seam.ssr.test.ts, runtime-server/src/tests/store-isolation-autowire.test.ts).


An unmapped member call in EXPRESSION position must WARN, never fall through to a verbatim emit

(PMTC, 2026-09). Both emitters dispatch a member call through a switch (prop) with no default: arm, so a method nobody wrote a case for — and a method whose case breaks because the SHAPE did not match — falls out of the switch into the generic member emit, which re-emits the callee VERBATIM. xs.toSorted() became Swift xs.toSorted() (value of type '[Int]' has no member 'toSorted') and Kotlin xs.toSorted() (unresolved reference), with zero warnings; 20 idioms were measured this way against real swiftc/kotlinc and 19 were silent mis-emits. The reported premise was wrong in a way worth recording: statement position was said to warn, and it does notemitSwiftStatement's case 'expr' delegates straight to emitSwiftExpr, so both positions share ONE emitter and there was no working warn path to mirror. Verifying the premise before mirroring it is what turned a 2-position fix into a 1-mechanism one. Three rules. (1) A switch that ends in a verbatim re-emit is a silent-mis-emit generator: the arm that says "nothing matched" is exactly the arm that needs a diagnostic. (2) A blanket fallthrough warning is WRONG here and measurement is what says somap/filter/forEach/reduce/flatMap/indexOf/lastIndexOf/substring/trim/split/startsWith/padEnd/padStart/repeat all reach the same fallthrough and COMPILE, because the native stdlib happens to spell them identically; warning on the fallthrough itself would fire on every one. The honest gate is an explicit set of the JS Array.prototype / String.prototype methods with no lowering, checked against a PROVABLY array-or-string receiver (an unknown receiver stays silent). (3) A method that HAS a case can still break out of it, so the set cannot catch it — sort is mapped for a 2-param comparator and xs.sort() (the commonest spelling) broke out into the verbatim emit; that one needs a warning inside its OWN case. The set is kept honest in the other direction by a test asserting no name in it has a case in either emitter, so mapping one forces its removal. codePointAt is in the set even though Kotlin COMPILES it (java.lang.String.codePointAt exists): JS returns number | undefined and is out-of-bounds-safe, Java returns int and THROWS — a silent semantic divergence is worse than a silent compile failure, not better. Reference: packages/native/compiler/src/unlowered-props.ts:UNMAPPED_ARRAY_METHODS/UNMAPPED_STRING_METHODS/unloweredSortWarning + the warnUnmappedMemberMethod guard at each emitter's generic member tail; locks native-emit-correctness.test.ts (loud + the no-false-positive half), closed-set-totality.test.ts (the set can never contain a mapped name) and 21 new entries in the LOUD-OR-TYPECHECKS canary native-idiom-sweep.test.ts. Bisect-verified: removing the guard fails with expected '' to contain '.toSorted()'; removing the sort warning fails the canary with SILENT MIS-EMIT — no warning AND swiftc rejects: value of tuple type '()' has no member 'count'.


A JS API whose contract is WIDER than the native one must be narrowed EXPLICITLY — and the divergence is usually silent on one target only

(PMTC, 2026-09; four instances found in one audit). A lowering that reaches for the same-named native primitive inherits that primitive's contract, not JS's. Every instance below compiles on both targets and answers differently, which is the worst shape available: nothing fails, the app is just wrong on one platform. (1) Math.round. Swift's .rounded() is toNearestOrAwayFromZero; JS Math.round is floor(x + 0.5), which breaks ties toward POSITIVE infinity. Executed on all three runtimes over [-0.5, -1.5, 2.5, -2.5]: JS 0 -1 3 -2, Kotlin 0 -1 3 -2, Swift .rounded() -1 -2 3 -3. Fix: ((Double(x)) + 0.5).rounded(.down). Kotlin needed NO change and the reason matters — java.lang.Math.round is SPECIFIED as floor(x + 0.5), so it was correct by a coincidence of Java's own choice of rule, not by anyone checking. (2) String.length. Swift's String.count counts GRAPHEME CLUSTERS; JS and Kotlin .length both count UTF-16 code units — "👍".length is 2, 2, and 1. This one had already been fixed at ONE call site (the form min/max-length validator, carrying this exact 👍 rationale in a comment) and left as folklore everywhere else: the general member emit still said .count, so every string-length comparison in shared source answered differently on iOS. Gate the switch on a PROVABLY-string receiver — an array's .length is correctly .count, and .utf16 does not exist on an array, so a wrong guess breaks the compile instead of the answer. (3) toFixed locale. Kotlin's 1-arg "%.2f".format(x) uses Locale.getDefault(), so the DECIMAL SEPARATOR follows the device: executed under Locale.GERMANY it yields 1234,57 where JS and Swift give 1234.57. Fix: "%.2f".format(java.util.Locale.ROOT, x). Swift's String(format:) with no locale argument is already invariant — verified rather than assumed, since the symmetric-looking fix would have been wrong there. A display number that changes shape per device round-trips differently, sorts differently and breaks any downstream toDouble(). (4) Record<K, V> index. A native dictionary subscript is OPTIONAL on both targets where a TS Record index is not — inherent to the map lowering, documented at the fixture rather than papered over. The rule: when a lowering picks a same-named native primitive, look up that primitive's ROUNDING RULE / UNIT / LOCALE / NULLABILITY and compare it against the JS spec — do not infer from the name. And when a correction lands at one call site, ask what INVARIANT it implies and apply it there, or the second instance is already written and just has not run yet. Detection: the regeneration of the committed native chart engine after these fixes changed 20 lines of axis-label formatting, i.e. the divergence was live in shipped chart output. Reference: emit-swift.ts (Math.round, the .length member emit) + emit-kotlin.ts (toFixed); locks in native-emit-correctness.test.ts, all bisect-verified.


Treating every function-valued prop as an event handler fabricates render props (@pyreon/atlas generated catalogs, 2026-09).

Atlas discovery marks callbacks as reactive, which says only that the value is a function. The generated catalog used that marker as the Actions-panel event list and injected a logger for every such prop — including optional children and renderItem. That changes behavior even when the author passed nothing: Combobox saw the fabricated function child, entered its render-prop escape hatch, called the logger, received undefined, and every deployed preview was an empty card with no runtime error. Rule: event instrumentation must use the framework's actual event contract (/^on[A-Z]/), not the wider function-valued category. Never synthesize an absent callback unless its API explicitly defines a default. A generated-code assertion must cover both halves: onSelect is instrumented, while children/renderItem remain absent; a static-build browser test must then prove the real component mounts, because string assertions alone cannot prove the generated module executes. The same browser pass exposed a second silent failure: a const m = props.model alias used in two adjacent statements inside a deferred handler was inlined as view.set(...)(props.model).selectScenario(...), so Docs scenario navigation threw while a visibility-only test passed. Keep an object meant to retain identity as a reference (let is currently excluded from prop-derived inlining), and make navigation tests assert zero pageerror events in addition to the destination UI. Reference: packages/tools/atlas/src/dev/catalog-module.ts, packages/tools/atlas/src/ui/views/docs/DocsView.tsx, catalog-module.test.ts, and the Atlas Playwright suites.


A derived scenario that says how a component may LOOK but never what it renders WITH — and a preview gate that passes on an empty element (@pyreon/atlas, 2026-09).

Discovery derived controls from PROPS and scenarios from dimension AXES, so every generated scenario mounted h(Button, { state, size }) with no children, no label, no src, no placeholder. The deployed @pyreon/ui-components workbench rendered all 108 components as empty shells — <button> 26×10, <h2> 0×0, an AspectRatio 0×0 — and atlas scan reported 1090/1090 verified, because "mounts, clicks and unmounts without throwing" is exactly as true of an empty element. The build e2e's "every component produces preview DOM" asserted :scope > * has a count above zero, which an empty <div> satisfies: a gate that cannot distinguish "mounted something" from "shows something" is a dead gate for this class (the same shape as hasAttribute masking aria-checked=""). Two rules. (1) A generator that derives inputs must derive CONTENT too, keyed on what the thing renders AS — the tag off the rocketstyle attrs chain (__rs_attrs, and __rs_component for a .config({ component: 'hr' }) base): a text tag gets the component's name, <img> a network-free placeholder src + alt, a field a placeholder, a layout container (by tag or a Stack/Grid/Box/Area/Ratio name suffix) placeholder BLOCKS it can arrange; <hr> nothing. Merged UNDER authored args (a value the author wrote always wins), serialized as JSON with the scenario, and turned into vnodes by ONE materializeContent used by the mount harness, the SSR-parity check AND the generated workbench render — the first cut fixed the harness alone and the SSR check then threw evaluating 'props.dangerouslySetInnerHTML' on the marker it never materialized, reported as the COMPONENT failing SSR. (2) A "renders" assertion must measure AREA (or text), not child count. The atlas build e2e now requires a bounding box with area for every emitted route, and the fixture carries a layout container so the blocks path is exercised, not just the label path. Corollary already in this file, met again: a const derived from props.model is inlined at every JSX site and the compiler injects its own _rp import, which collides with an explicit one — let is the opt-out. Reference: packages/tools/atlas/src/core/content.ts + discover/rocketstyle.ts:readTag + plugins/content.ts; locks core/tests/content.test.ts, plugins/tests/{mount,ssr-parity,content}.test.ts, e2e/atlas-build.spec.ts. Three things enabling the checks then surfaced, worth their own lines. (a) createUniqueId() is a process-wide counter that NOTHING resets — the _resetIdCounter its comment says SSR calls per request has no caller — so the SSR render, the hydrate and a fresh client mount each mint different pyreon-N ids; the parity oracle now canonicalizes the number, but the real hazard stays OPEN: a hydrated component's closure holds a different id than the server attribute the adopted DOM kept, so a listbox that mounts later carries an id its aria-controls never points at. A hydration-stable id scheme (tree position, or a per-root offset the server emits) is the fix, and it is a framework change, not an atlas one. (b) A static check gated on REQUIRED props skips every rocketstyle library wholesale — a chain declares no required props — so the a11y check now also verifies a name-like prop the scenario SUPPLIES. (c) applyProps' getter branch handed a getter's VALUE to the static sink unresolved, so a primitive helper's tabIndex: () => 0 | -1 spread through a rocketstyle element stringified the closure (12 warnings per scan, invisible in the DOM because tabIndex coerces the text to 0) — mirror applyProp's function branch inside the tracked frame.


Lifecycle & Cleanup Mistakes