pyreon

Reactivity 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.

Bare signal in JSX text

{count()} → wrap in {() => count()} or let the compiler handle it


[FIXED, 2026-07] Module-level tracking collectors set-then-NULLED instead of saved-then-RESTORED around nested reactive frames.

effect's run body + a computed's first eval each set a module-level thread-local (cleanup collector / deps collector) and reset it to a FIXED value (null) on exit — correct only at depth 1. Real shapes nest: a nested effect() created inside an outer effect's body nulled the cleanup collector on its run's exit, silently DROPPING every outer onCleanup() registered after that line; a dirty computed's FIRST eval inside an effect run nulled the deps collector, so the effect's subsequent signal reads were subscribed but never RECORDED → subscription retained after dispose() (leak class B). Rule: module-level thread-local frame state (collector/owner/index) must be captured into locals at frame entry and RESTORED — never reset to a constant — at frame exit. The tracking.ts frame helpers (runCollect/runVerify) centralize the save/restore; never hand-roll a set/null pair around a tracked evaluation. Both shapes bisect-locked in packages/core/reactivity/src/tests/verify-deps.test.ts ("frame-restore regressions"). THIRD instance, 2026-08, one layer up — the LIFECYCLE frame. runWithHooks opened the component setup frame with setCurrentHooks(hooks) and closed it with setCurrentHooks(null), and component setup nests for a reason that is easy to miss: the compiler lowers an element with a conditional/.map child to _tpl(html, bindFn) whose bindFn calls _mountSlot(...), and _tpl runs bindFn SYNCHRONOUSLY at its call site — so const box = <div>{show && <Child/>}</div> performs Child's entire runWithHooks partway through the parent's own setup. The inner exit reset the frame to null, so every onMount/onUnmount/onUpdate/onErrorCaptured the parent registered AFTER that line was silently dropped — with only a dev warning blaming the user for calling a hook "outside component setup". Fixed by restoring getCurrentHooks(). The generalisation worth carrying: "does this nest?" is not answerable from the calling code alone — it depends on what the COMPILER emits, and _tpl's eager bind makes a child mount look like a plain expression. Bisect-locked at BOTH layers: packages/core/core/src/tests/lifecycle-frame.test.ts (unit) and packages/core/runtime-dom/src/tests/nested-setup-hooks-frame.test.tsx (compiled through the REAL transformJSX, since vitest's own JSX transform never emits _tpl/_mountSlot and therefore cannot reproduce the nesting at all — the first spec there guards that premise explicitly).


[FIXED, 2026-07] An INLINE write-time computed recompute must NOT dispatch its DIRECT (_d1/_d) subscribers mid-batch — dirty-mark inline, DISPATCH in the drain.

#2284/#2296 made a lazy computed's recompute run INLINE during a signal write's notify phase (Preact-style write-time dirty-marking — the perf win). But that recompute ALSO fired the computed's DIRECT subscriber (read._d1() — the compiled {someComputed()} _bindText/_bindDirect shape) INLINE, right there. Inside a multi-write batch(() => { a.set(); b.set() }) the computed's _v is TORN at that point (a written, b not), so the direct binding fired on the HALF-updated value AND re-fired on the next write → [12, 30] instead of one settled [30]; a torn eval that THROWS (items()[idx()]!.toUpperCase() mid-swap) even dispatched a PHANTOM production error through _errorHandler/__pyreon_report_error__. Effects + eager { equals } computeds were glitch-free (they queue) — a lazy computed's direct subscriber was the ONLY inconsistent kind. Rule: an inline write-time recompute may MARK dirty + propagate, but the actual subscriber NOTIFICATION (direct-updater dispatch) must be DEFERRED to the batch drain (enqueuePendingNotification), where it fires ONCE with fully-settled values — exactly the deferral a SIGNAL's _d1 already gets under batch. The computed's own _dirty guard makes the enqueue idempotent (a re-entered recompute early-returns before enqueuing). Reference: packages/core/reactivity/src/computed.ts (recompute enqueues _d1/_d instead of calling them). Sibling half of the same PR — a RECURSIVE write-time cascade needs an ITERATIVE (or depth-BOUNDED) form, else overflow → SILENT STALE. #2296's inline hop (propagateLazyDirty → recompute → propagateLazyDirty → …) was RECURSIVE; a deep chain (~8000+) overflowed the JS stack, the caught RangeError cleared a computed's _dirty with a STALE value (tail reads 8000 not 8001, permanently) — 0.45.0 propagated iteratively and was correct at 10,000. Fix: recurse inline for the common SHALLOW case (a _cascadeDepth counter read into a local + bumped once per level — measured at PARITY with the pure-recursive form; a naive per-hop counter regressed chain-50 ~20% + a pure-iterative stack regressed the diamond ~12%) and switch to an EXPLICIT stack only past MAX_CASCADE_RECURSION, so the live JS stack is BOUNDED at any chain length. General rule: any framework code that recurses over an unbounded reactive graph on WRITE (dirty cascade) must be depth-bounded/iterative — a recursion whose overflow is swallowed by a downstream try/catch becomes a SILENT lost update, worse than a loud crash. Note the lazy PULL-READ is inherently recursive (same as 0.45.0); the deep-chain regression lock reads BOTTOM-UP (each read O(1)-shallow) + configures --stack-size so the read has the headroom the "correct at 10k" bar assumes. Bisect-locked in packages/core/reactivity/src/tests/batch-glitch-freedom.test.ts (revert → [12, 30] / ['C', 'X'] / RangeError; restore → one settled fire, zero error dispatches, correct 10,001).


[FIXED, 2026-07] A drain that visits queued recomputes in SUBSCRIPTION order while relying on visit-time PULLS of un-dirtied deps + dedup that drops re-notifies of visited entries — the tier-1 topo-staleness class (pre-existing, 0.45.0-identical).

An { equals } computed re-evaluated ONLY inside its queued recompute and never set _dirty on notification. Tier-1 drains in SUBSCRIPTION order, not topological order — outer = computed(() => s() + inner(), { equals }) that subscribed to s BEFORE inner drained first, pull-read inner() (enqueued-but-NOT-dirty → returned the stale cache), and inner's later re-notify of outer was dropped by the drain's Set-dedup (already visited) → outer() PERMANENTLY stale (12 vs 22) until the next write — silently falsifying the "computeds settle before effects" contract, and a torn stale-pull that THROWS dispatched a phantom _errorHandler error. Two rules, both halves required: (1) subscription order ≠ topological order — a queued evaluator must never trust a pulled dep to be fresh unless dirtiness is established at NOTIFY time. Dirty-mark EVERY computed (eager included) inline during the write's notify phase; the visitor's pull-read then evaluates a dirty dep in place and visit order stops mattering for dirty-at-visit deps (zero double evals, no torn evals). (2) a drain's dedup must NOT drop a re-notify of an already-visited node whose input changed AFTER its visit — dirtiness through a LAZY intermediate only materializes when the upstream { equals } computed refreshes mid-drain, so clear the entry's queue-membership flag BEFORE running it (the array analogue of delete-before-run; Set form: delete inside the iteration — a re-added value is a fresh insertion the live iterator visits) and let the re-push trigger another idempotent, _dirty-guarded sweep. Convergence: a re-push costs a real upstream change + equals short-circuits, so a DAG settles in ≤ depth sweeps. Perf lesson: the fix moved the eager unbatched write from a direct inline call to a queue round-trip — the first (Set-based) tier-1 shape cost ~3-4× on the eager micro; re-using the #2284 array+intrusive-flag queue design for tier-1 recovered it to lazy-variant parity (and made multi-write batches ~30% faster). The old 40M-ops/s inline speed was the BUG (torn-unsafe + topo-stale) — don't chase it. Bisect-locked in packages/core/reactivity/src/tests/tier1-topo-staleness.test.ts (revert → expected 12 to be 22 ×4 + a phantom-error spec; restore → pass). Reference: packages/core/reactivity/src/computed.ts:computedWithEquals (notify = dirty-mark + enqueueEagerRefresh; the READ's dirty branch is the single evaluator) + batch.ts:recomputeQueue (clear-flag-before-run drain).


Stale closures

signal.peek() captured in long-lived closures loses reactivity


An effect that bumps a version signal it also READS re-runs itself once per batch pass — 32 times, silently

(the canvasHost a11yVersion instance, 2026-09). effect(() => { track(); version.set(version() + 1); draw() }) subscribes the effect to version through the read, so its own write re-queues it; the two-tier batch drains it up to MAX_PASSES (32) and stops without a warning. The symptom was NOT a loop but a cancelled animation: every re-run called draw(), which saw "nothing changed" and cancelled the update tween the first run had started, so the tween never ticked. Rule: a counter an effect maintains for OTHER readers is written with .peek() (version.set(version.peek() + 1)) — never read inside the effect that writes it. Detection: the repeated draw() was invisible until a console.log counted it; a tween that "snaps" with reduced-motion off and a same-shape frame is this class first. Reference: packages/fundamentals/charts/src/engine/canvas-host.tsx (the draw effect).


Missing batch

3+ signal updates without batch() → unnecessary re-renders


Nested effects

effect() inside effect() → use computed() for derived values


Signals in hot paths

Creating signals inside render functions or loops → create once at component setup


Reading .peek() in effects/computeds

Bypasses tracking, creates stale reads


A single version-counter bumped by BOTH content AND selection events makes content computeds re-run on a pure cursor move (engine-wrapper reactivity)

When wrapping a stateful imperative engine (ProseMirror/TipTap, CodeMirror, a table lib) in signals, the idiom is a version = signal(0) bumped on every engine transaction; computeds read version() then engine.peek().getX() so they re-derive per change. The trap: an editor fires SEPARATE update vs selection events (TipTap onUpdate vs onSelectionUpdate), and Pyreon's default computed notifies UNCONDITIONALLY on a dep change (only { equals } gates) — so if ONE counter absorbs both event kinds, a pure cursor move (no content change) re-runs EVERY content-derived computed (text/html/characterCount/wordCount/canUndo) and every effect reading them. A live word-counter re-fires on every arrow-key; an html-format two-way binding writes its signal on every click. Fix: split the counter by event semanticsdocVersion (content: onUpdate) and selectionVersion (onSelectionUpdate); content computeds subscribe to docVersion ONLY, selection-dependent ones (isActive) to both. Better still, derive the value from the DOCUMENT signal, not the engine@pyreon/rich-text's characterCount/wordCount/isEmpty walk the ProseMirror-JSON signal (baseJson), so they're content-reactive by construction (baseJson only changes on content), work before mount, AND count visible characters (no getText() block separators). Reference: packages/fundamentals/rich-text/src/editor.ts (the docVersion/selectionVersion split + pure JSON walkers); regression rich-text.browser.test.tsx ("a pure selection move does NOT re-run content computeds") + rich-text.test.ts (pre-mount JSON-derived counts), both bisect-verified.


Ternary short-circuit hiding signal tracking inside a reactive accessor

{() => fields.title.touched() ? fields.title.error() ?? '' : ''} is a quietly broken pattern. When touched is false on first render the ternary short-circuits → error() is NEVER read → the effect doesn't subscribe to it → a later error.set('...') doesn't trigger re-render. The accessor re-runs when touched flips, but the validator that flipped it may set the error in the SAME batch — at re-run time error() is still undefined, and now the effect subscribes only to the current value (undefined), not future ones. Same for cond && sig(). Fix: read both signals into a const BEFORE the conditional, so the effect subscribes to both on first render: {() => { const t = fields.title.touched(); const e = fields.title.error(); return t ? e ?? '' : '' }}. This is fundamental fine-grained reactivity (matches Solid / Preact-signals / MobX) — not a Pyreon-specific bug. Real-world hit: HN-clone audit #942 W11 — form field-errors stayed empty after submit even though the schema validator correctly set them. Documented in docs/src/content/docs/reactivity-rules.md "Conditional Reads Hide Tracking". Hard to detect statically with high precision (any conditional with a signal call in one branch is suspect, but false-positive risk is high — many such expressions ARE correct by construction because both signals come from a shared upstream).


[FIXED, 2026-07] A whole-form schema error keyed by a path that doesn't match a top-level field is silently dropped → the form submits invalid data.

@pyreon/form's validate() applied schemaErrors[name] only for name in the top-level fieldEntries. But the zod/valibot/arktype adapters (issuesToRecord) flatten a nested issue to a DOT-PATH key ("address.city"), and path-less/whole-form errors land under the "" key — neither matches a top-level field, so the error was discarded and validate() returned true (VALID) while the schema had REJECTED. A form with an object-valued field (initialValues: { address: { city: '' } }) + a nested schema reported valid + fired onSubmit with empty data (verified with a runnable repro: validate() ⇒ true, errors() ⇒ {}). Fix (both the submit validate() AND the blur runSchemaForField paths): matchSchemaErrorForField(schemaErrors, name) matches an EXACT key OR the first nested key whose top-level segment is this field (address.cityaddress), so a nested rejection surfaces on its ancestor object field; orphanSchemaErrorKeys(schemaErrors, fieldNames) collects keys whose top segment matches NO field (typo / "" whole-form error) → those mark the form INVALID + set submitError + dev-warn. General rule: any code that maps a validator's keyed error record onto a fixed field set must handle keys that match no field — route them (to an ancestor or a form-level error), never drop them; "matched no field" must mean INVALID, not valid. Both helpers are pure + unit-tested; the end-to-end drop is bisect-verified (revert → 5 specs fail, validate() returns true). Reference: packages/fundamentals/form/src/use-form.ts:matchSchemaErrorForField/orphanSchemaErrorKeys + tests/schema-error-routing.test.tsx. Update (dot-path leaf fields): a field key with a dot ("address.city") is now a FIRST-CLASS leaf field — per-field validators and a FLAT-keyed schema route the error to the exact LEAF, while a NESTED schema over a single object field still routes to the ANCESTOR. The load-bearing follow-on fix was in orphanSchemaErrorKeys: it flagged a leaf key address.city as an orphan (its top segment address is not a field), so a leaf field + schema produced a SPURIOUS submitError even though the error had already routed to the leaf — the fix checks exact-field membership (fieldNames.has(key)) and deep-ancestor membership (nearestAncestorField) before flagging. General rule (reinforced): when a routing function assigns keyed errors to a fixed field set, its ORPHAN/leftover detection must use the SAME match logic as the assignment — checking only a key's TOP segment silently disagrees with an exact/leaf/deep-ancestor assignment and false-flags routed errors. The value model stays FLAT (values()/onSubmit keep dot-path keys — honest types, no NestValues<T> cascade that would break generic wrappers like @pyreon/feature); nestValues/flattenValues convert to/from a nested payload. Bisect-verified in tests/nested-paths.test.tsx (revert orphan fix → leaf error routes but submitError is spuriously set) + tests/path.test.ts (incl. a nestValues prototype-pollution guard — a crafted "__proto__.polluted" key is dropped). Update 2 (auto-split a NESTED schema to per-leaf fields — the runtime residual now CLOSED): a DECLARATIVE schema (a raw Standard Schema or @pyreon/validation typed adapter — never a plain function) over a dot-path-leaf form now receives the NESTED value shape (transiently rebuilt from the flat model via nestValues in getSchemaInput, gated on schemaIsDeclarative && ≥1 dot-path field), so a REAL nested z.object({ address: z.object({ city }) }) validates correctly and its per-leaf-path error (address.city) auto-splits to the registered LEAF field. Routing now PREFERS the most-specific field: matchSchemaErrorForField(schemaErrors, name, fieldNames) takes the field set so an ancestor object field no longer claims a nested key owned by a registered LEAF (or a deeper registered object ancestor) — resolving the both-registered tie-break to leaf-wins. A plain SchemaValidateFn always gets the FLAT TValues (its type contract) → zero change for imperative schemas + all top-level/object-field forms. General rule (reinforced again): the auto-split needs BOTH halves — feed the declarative schema the shape it's authored against (nested), AND route each of its keyed errors to the most-specific registered field; a routing fix alone (without nesting) leaves a real nested schema producing a structural ancestor/orphan error that can't split. Bisect-verified in tests/nested-schema-split.test.tsx (revert the fieldNames leaf-preference → the address.city error lands on the ancestor; revert nesting → the real nested schema's leaf error vanishes + a valid nested payload fails validation). Remaining: typed deep-path inference (values()/onSubmit/schema typed against the flat keys, not NestValues<T> — a nested declarative schema needs an as never cast; the type cascade breaks generic wrappers like @pyreon/feature, so it stays a tracked follow-up).


Accessor interpolated UNCALLED in a template literal

`${itemWidth}%` where itemWidth = computed(...) renders the function SOURCE, not the value — a CSS width/text silently becomes () => … (upstream-reported shipped bug; the compiler's signal auto-call pass covers JSX expression regions, NOT template interpolations, so nothing rewrites or warns). Fix: call it — `${itemWidth()}` — and build the template inside a tracking scope (effect / computed / JSX accessor) when the result should be reactive. Detector gating (zero-FP doctrine): TRACKED bindings only — const x = signal(...)/computed(...), plus const x = useX(...) hook results ONLY when the file also calls x() plainly (a hook may return a plain value — useId() → string — where bare interpolation is correct); tagged templates are excluded (a css/styled tag legitimately receives function interpolations); names re-declared anywhere by a non-accessor binding (param, destructure, import) are ambiguous under the scope-blind collector and never fire.

Detected by: accessor-uncalled-in-template — surfaced by @pyreon/lint / pyreon doctor / MCP validate.


Accessor used BARE as an if/ternary condition (outside JSX)

an accessor is a FUNCTION — always truthy — so if (!has) is dead code and if (has) takes the truthy branch forever (const has = useHasAnyItem(); if (!has) return null never returns null; the value is never consulted). Fix: call it — if (!has()) — and remember the surrounding body runs ONCE: render-time branching belongs in <Show when={() => has()}> or a returned reactive accessor. Inside JSX the compiler auto-calls known signals in conditionals, so the detector fires outside JSX only. Gating mirrors the template sibling (tracked bindings; hook tier evidence-gated since useRouter()-style results are legitimately nullable and if (!router) is a real existence test; typeof x === 'function', x == null, property access, and guard shapes where the name is called in the same statement never fire; ambiguous re-declared names skip).

Detected by: accessor-uncalled-in-condition — surfaced by @pyreon/lint / pyreon doctor / MCP validate.


Destructuring props

(parameter shape) [detector: props-destructured-body] (body shape): const { state } = props (or ({ state }) => …) captures getter values once — loses reactivity. The compiler emits signal-driven props as getters, so the destructured locals are dead snapshots that never update when the parent rewrites them. Use props.state directly inside the reactive scope (JSX / effect / computed), or splitProps(props, ['state']) to carve out a group while preserving reactivity. Both shapes are now statically caught by detectPyreonPatterns (the TS-compiler-API detector — full AST + scope, NOT the lightweight oxc lint walker the prior "doc-only cliff" note referred to): detectPropsDestructured flags the parameter-destructure shape function C({ state }){…}; detectPropsDestructuredBody flags the body-scope shape const { state } = props written SYNCHRONOUSLY in a component body. Body-scope precision (zero false positives is the priority): PascalCase JSX-rendering functions PLUS the component-by-POSITION HOC shape — an anonymous RETURN-POSITION arrow whose first param is literally props (const withX = (W) => (props) => {…}, concise or block body; the shipped gap where the anonymous inner component got "No issues found" until named — isReturnedPropsComponent; render-prop children and .map callbacks are ARGUMENTS not returns → excluded, and the recommended-fix accessor return (() => …) has no params → excluded); only = props where props is the bare first-parameter identifier (unwrapped through as / satisfies / ! / parens); the destructure must be at the component-body top scope — a nested-function boundary (onClick handler, effect(() => …), a returned reactive accessor) re-reads props per invocation and is reactivity-correct, so the walk does NOT descend into nested functions. Known limitations (deliberate, conservative): const { x } = props.nested and = someOtherObject are NOT flagged (rarer shapes); a const { x } = props inside onMount(() => …) is not flagged (different/rarer shape — runs once post-mount). The Reactivity Lens (analyzeReactivity in @pyreon/compiler; LSP inlay hints via @pyreon/lint --lsp) complements the detector downstream: it renders static ghost-text on <div>{state}</div> when the compiler proves that expression is NOT reactive — making the captured-once death visible at the cursor even for the limitation shapes the detector deliberately doesn't name.

Detected by: props-destructured — surfaced by @pyreon/lint / pyreon doctor / MCP validate.


JSX spread on a component value-copies getter-shaped reactive props

(compiler-fixed end-to-end as of 2026-05-14): esbuild's automatic JSX runtime compiles <Comp {...source}> to jsx(Comp, { ...source }). The JS-level object spread fires every getter on source and stores resolved values — collapsing compiler-emitted reactive props (_rp(() => signal()) wrappers converted to getters by makeReactiveProps) to static values before Comp ever sees them. Fix lives in the Pyreon compiler (both JS path in packages/core/compiler/src/jsx.ts:handleJsxSpreadAttribute AND Rust binary in packages/core/compiler/native/src/lib.rs:handle_jsx_spread_attribute): for any component JSX with {...source}, the compiler emits <Comp {..._wrapSpread(source)}>. _wrapSpread (packages/core/core/src/props.ts) walks source's own keys via Reflect.ownKeys (no getter firing) and returns a new object whose getter-shaped values are re-branded as _rp thunks pointing back at the original. JS spread then copies the brands as plain data property values; makeReactiveProps converts them back to getters in the mount pipeline — preserving the reactive subscription end-to-end. Runtime fast path: sources with no getters return unchanged, so static spreads pay zero cost. DOM-element spreads (<div {...rest}>) are untouched — they go through the template path's _applyProps which already handles reactivity. Bisect-verified: reverting the JS path's replacements.push line fails JSX transform — component elements > spread props on component are wrapped with _wrapSpread to preserve reactivity with expected 'const w = <Wrapper {...rest}…' to contain '{..._wrapSpread(rest)}'.


Manual prop-pipeline wrappers value-copying getter-shaped reactive props

(framework-internal fixes; user code now handled by the compiler-level fix above): Pyreon's reactive-prop contract is that <Comp prop={signal()}> compiles to h(Comp, { prop: _rp(() => signal()) }), mount.ts runs makeReactiveProps to convert _rp-branded thunks into property GETTERS, and any downstream consumer reading props.prop inside a tracking scope (JSX accessor, effect, computed) subscribes to the underlying signal. Wrapper / HOC pipelines that COPY props via result[key] = source[key] in plain JS (not JSX spread) break this contract — the value-read fires the getter at HOC setup time (outside any tracking scope), captures the resolved value, and stores it as a data property on the new object. Every downstream JSX accessor reading props.prop then sees the captured-once value, never re-subscribing to the signal. Same for spread ({...A, ...B}) and Object.assign — both are value-read + value-write. Fix shape: use Object.getOwnPropertyDescriptors(source) + Object.defineProperty(target, key, descriptor) to copy DESCRIPTORS instead of values. For data properties this is identical; for getter properties it preserves the live getter on the destination object. Real-world hit: @pyreon/rocketstyle's removeUndefinedProps + 4 spread sites (HOC + EnhancedComponent) AND @pyreon/styler's buildProps AND @pyreon/ui-core's omit / pick all value-copied reactive props from package inception. Every signal-driven prop on every rocketstyle-wrapped component (the whole of @pyreon/ui-components, plus user-defined ones) silently lost reactivity. No existing test caught it because no test passed a signal-valued ordinary prop through the rocketstyle pipeline and asserted reactive DOM patching. @pyreon/kinetic was the last residual hit (correctness long-tail sweep): createKineticComponent's prop split was a for (const key in props) { htmlProps[key] = props[key] } value-copy, the children carve-out was const { children, ...restHtml } = htmlProps (rest-destructure = value-copy), AND all four renderers re-spread { ...htmlProps } into their h(config.tag, …) call — so every reactive HTML attr on every kinetic('div')-wrapped component (transition / collapse / stagger / group) froze at first render from package inception. Fixed by routing the split through splitProps(props, [...KINETIC_KEYS]) + a second splitProps(…, ['children']) for the children carve-out, and replacing the renderer re-spreads with by-reference pass (Stagger/Group — no extra props) or mergeProps(htmlProps, { ref, style }) (Collapse/Transition — ref + animation-controlled style override last-wins). The descriptor survives to h(config.tag, htmlProps) where runtime-dom's applyProps detects descriptor.get and wraps the read in renderEffect. Reference: packages/ui-system/kinetic/src/kinetic/createKineticComponent.tsx + {Collapse,Group,Stagger,Transition}Renderer.tsx; bisect-verified at the real-Chromium layer (packages/ui-system/kinetic/src/__tests__/kinetic.browser.test.tsx — broken: expected 'a' to be 'b' on 3 reactive-forwarding specs, 5 non-reactive specs still pass; restored: 8/8). Reference: packages/ui-system/rocketstyle/src/utils/attrs.ts:removeUndefinedProps + the canonical mergeProps from @pyreon/core (which superseded the former rocketstyle/attrs-internal mergeDescriptors helpers — a sweep consolidated every framework descriptor-safe-merge call site onto the single canonical API), packages/ui-system/styler/src/forward.ts:buildProps, packages/ui-system/ui-core/src/utils.ts:omit / pick. General rule for any wrapper that forwards user props: copy descriptors, never values. Plain result[key] = source[key] is correct only when the source is known to carry no getters — which user-supplied props can never satisfy because the compiler emits them as _rp thunks that makeReactiveProps converts to getters. Companion writes (finalProps.ref = ..., finalProps.X = newValue) must use Object.defineProperty with a data descriptor — plain assignment to a getter-only descriptor is a silent no-op in non-strict mode and throws in strict mode. The descriptor-aware "drop undefined keys, keep getters" filter is now a single @pyreon/core primitive — removeUndefinedProps, sitting next to mergeProps / splitProps / makeReactiveProps (it operates on core's own _rp encoding). @pyreon/attrs and @pyreon/rocketstyle previously hand-rolled it identically, and the @pyreon/attrs copy historically shipped as a value-copy that silently broke reactive forwarding for attrs(Component) consumers — the exact divergence a single canonical home prevents. Both packages' utils/attrs.ts now re-export it from core. When you need to filter undefined keys off a props object before merging, use removeUndefinedProps from @pyreon/core — never write a fresh value-copy or descriptor-copy loop. 2026-07 whole-class sweep (@pyreon/elements, the Text.label fix's siblings) — the same eager-read freeze hides in FOUR more shapes beyond prop-copying loops: (1) body/parameter DESTRUCTURES (Iterator's 8-prop body destructure + one-shot return renderItems(), useOverlay's parameter destructure — <Overlay disabled={busy()}> never re-enabled, Util); (2) render-prop objects built with EAGER signal reads (Overlay's trigger got active: active() at setup — aria-expanded frozen at false forever, an a11y bug; fix = pass _rp(() => active()) accessors so the binding is live WITHOUT re-rendering the trigger, which would destroy the focus-restore target); (3) values baked into styling-layer bundles at setup (Element/Wrapper/Content's interned $element + Text's $text froze every signal-driven layout/css prop — fix = a two-path design: getter-shaped props detected via a cheap descriptor scan (hasGetterProps) switch the bundle to an ACCESSOR the styler's DynamicStyled now treats as a reactive axis exactly like $rocketstyle — class swap on the same element, static case byte-identical; the reactive path must BYPASS identity-keyed class caches + CPSE, whose per-instance style vars a class-swap can't update); (4) slot-EXISTENCE branches pinned at setup (isSimpleElement = !own.beforeContent && !own.afterContent — fix = run the body in a reactive accessor when slots are getter-shaped; structural remount is correct when the DOM shape changes). Reactive-Iterator corollary: a reactive list body must NOT return fully-keyed vnode arrays baked from plain props — mountKeyedList never re-renders surviving keys, so index-keyed rows would FREEZE on data flips; whole-list replacement (keys removed) is the correct semantic, and per-item injectors must be pure (the mount pipeline classifies every accessor child at mount: a TEXTISH accessor's first TRACKED run IS the classification call — single invocation, mountAccessorChild in runtime-dom's mount.ts — while keyed-array/general accessors are still sampled untracked before their machinery's tracked first run, i.e. run twice at mount). Reference: packages/ui-system/elements/src/utils.ts:hasGetterProps + styler/src/styled.tsx (isReactiveEl/bypassElCacheAndCpse); regression locks elements/src/__tests__/reactive-prop-class.test.tsx (bisect-verified per instance) + reactive-prop-class-sweep.browser.test.tsx (real Chromium: computed-style flips, trigger identity + focus restore).


Fixing a getter-valued prop POSITIONALLY, one prop at a time, when the compiler's emission is not prop-specific — and the second half of the fix is UNTRACKING, not liveness

(the @pyreon/kinetic instance, 2026-09). show={isOpen} rendered permanently invisible because every kinetic surface read props.show at SETUP; #3387 fixed exactly that prop and recorded, correctly, that transition, timeout, interval and the callbacks carried the identical freeze. They did: measured on that tree, <Transition onEnter={_rp(...)}> fired the handler captured at MOUNT (the current one never fired), enter={_rp(...)} applied the class the signal had left behind, and kinetic('div')'s kineticProps destructure froze all ten at once — in a function whose own comment explains that the splitProps two lines above exists to preserve those getters. Nothing about _rp is specific to show, so a positional fix leaves a hole shaped exactly like the feature; enumerate the whole holder. Three durable rules. (1) LIVE must not mean TRACKED. watch(source, cb) runs cb INSIDE the effect's tracking scope (reactivity/src/watch.ts), so reading a getter-backed config prop there SUBSCRIBES the state machine to its own styling: changing an easing string mid-flight re-enters the callback and RESTARTS the animation, re-firing onEnter. Bisect-verified — dropping runUntracked fires onEnter 3× for one flip and makes swapping a handler invoke it immediately. So show (the machine's INPUT) is read TRACKED and every other prop is read LIVE-BUT-UNTRACKED. The two helpers are deliberately separate (showAccessorFrom vs readLive), and readLive must NOT auto-resolve a function, because a callback prop's current value IS one. (2) Decide per prop, and write the reason at the site. appear is a first-mount question whose latch is spent once the ref wires up; a stagger interval is baked into a resolved child array's static style objects, where a live value would need a function-valued style on children that may be COMPONENTS — those stay plain reads WITH a comment. Blanket-wrapping is as wrong as blanket-freezing. (3) A JSX spread is a plain object spread wherever the Pyreon compiler does not run — and it does not run on the framework's own packages, which build through the ordinary automatic runtime. <Transition {...transitionProps}> therefore READ every key and froze every forwarded prop that splitProps had just preserved; h(Transition, mergeProps(transitionProps, {…}), child) copies descriptors instead. Detection: the probe that settles it is four lines — pass _rp(() => sig()), move the signal, flip, and print what the DOM/spy got; a plain-value control alone can never see this. Reference: packages/ui-system/kinetic/src/live-prop.ts (readLive / readCallbacks / readLiveValue) + show-accessor.ts; locked by __tests__/live-props.test.tsx (happy-dom) and the kinetic.browser.test.tsx block (real computed style + a real transitionend), every leg bisect-verified.


Resolving a getter-valued content-prop fallback chain (children ?? content ?? label) EAGERLY at setup instead of inside an accessor

distinct from the descriptor-copy class above — here the props ARE preserved as getters through splitProps (descriptors copied verbatim), but a wrapper that computes its rendered child as const child = own.children ?? own.content ?? own.label (or passes children: own.children ?? own.label as a plain override VALUE) reads each getter ONCE via ?? at component setup (components run once), capturing the resolved value. A compiler _rp()-getter — what <Text label={sig()} /> lowers to — is then frozen; the signal changes but the text never updates. <Comp>{sig()}</Comp> (a JSX-CHILD accessor) is unaffected — the child arrives already wrapped as () => sig(), a function that mountChild mounts reactively; the bug is specific to the getter-valued label/content/children PROP resolved by ??. Fix: pass children as an ACCESSOR — children: () => own.children ?? own.content ?? own.label — so mountChild re-reads the fallback (and each underlying getter) on every change. Real-world hit: @pyreon/elements' Text shipped children: own.children ?? own.label from inception; PR #1168 fixed the sibling REST-prop boundary (href/title via mergeProps descriptor copy) but left this children read eager, so Text label={sig()} stayed non-reactive. Element already did it right (getChildren = () => own.children ?? own.content ?? own.label, passed as children: () => resolveSlot(getChildren())) — the two diverged. Fixed by wrapping Text's override in an accessor. General rule: any wrapper that resolves a content/children fallback chain over props MUST wrap the ?? chain in an accessor (() => …), never read it at setup — the ?? fires the getters, and a getter-valued reactive prop dies the moment it's read outside a tracking scope. Detection trap: a mock-vnode unit test asserting result.props.children "toBe" the resolved value CODIFIES the broken eager shape (it compares against the value, and once children is an accessor it's a function — assert props.children() instead). The load-bearing test is a real-Chromium mount that flips a _rp(() => sig())-valued label and asserts el.textContent updates. Reference: packages/ui-system/elements/src/Text/component.tsx (accessor children) vs Element/component.tsx:getChildren; bisect-verified in __tests__/reactive-prop-through-element.browser.test.tsx (revert → expected 'live-1' to be 'live-2').


Detecting "has direct subscribers" by reading signal._d alone

PR #1177 added a two-tier subscriber store on signal()_d1 is an inline slot for the FIRST direct subscriber (~all per-row label/class bindings inside <For> rows fall here); _d is a Set allocated lazily only on PROMOTION when a 2nd subscriber arrives. Any consumer that checks if (signal._d && signal._d.size > 0) to decide "is anyone subscribed?" gets a false-negative on every single-subscriber signal — _d is null when only _d1 is in use. Real-world hit: @pyreon/solid-compat's sweepUnusedSignals (packages/tools/solid-compat/src/index.ts) was about to evict LIVE store-backing signals because every per-row signal has exactly 1 direct subscriber — caught and fixed during the PR #1177 cross-package audit. Fix shape: check BOTH tiers — const hasDirect = sigInternal._d1 !== null || (sigInternal._d && sigInternal._d.size > 0). The TRACKING channel is two-tier too as of #2973 (_s1 inline slot -> _s Set), so the rule now covers _s identically — and it recurred there exactly as predicted: solid-compat's sweep tested _s alone, reported "unused" for the dominant single-subscriber shape, and EVICTED A LIVE SIGNAL. Two further _s-only readers had to be fixed with the tier: the devtools graph (an _s-only walk blanks the subscriber counts and drops nearly every EDGE, while the node list still looks right) and createSelector's reclamation sweep — where it is a CORRECTNESS break rather than a stale number, because sweeping a live bucket strands its effect, which is then never notified again. The helper now exists: _hasSubscribers(sig) in @pyreon/reactivity covers all four fields (_s1/_s/_d1/_d) — use it instead of hand-rolling a presence check, and inside reactivity use addSubscriber/removeSubscriber from tracking.ts rather than touching the fields. Two obligations the tracking tier adds beyond "read both": (1) the write path dispatches _s1 OR _s, never both, so ANY code that adds straight to _s must first promote an inline occupant out — a subscriber stranded behind an occupied _s1 goes silently dead, and the ordering is reachable in practice because @pyreon/store wires its per-field change detectors lazily on the FIRST api.subscribe(), i.e. after components have already tracked the field (locked by store/src/tests/subscribe-after-tracked-read.test.ts); (2) addSubscriber must stay IDEMPOTENT — one tracking frame that reads the same source twice records the host twice, and a non-idempotent add promotes it out of the inline tier: still correct, but it silently hands every double-read source a Set allocation plus a hashed teardown. Any code reading _d MUST also check _d1, and any code reading _s MUST also check _s1 — and, since 2026-09, must accept _s holding a FUNCTION (the SECOND inline tracking subscriber; a Set there means ≥3): _s.size/_s.delete/iteration on a function throws or silently no-ops, so go through _hasSubscribers/_tierCount instead. Reference: packages/core/reactivity/src/signal.ts (SignalFn._d1/_d/_s1/_s, _hasSubscribers) + tracking.ts:SubscriberHost; canonical external fix in packages/tools/solid-compat/src/index.ts.


[EXTENDED, 2026-09] A component PROP now reaches the direct tier too — <Row value={sig()} /> + {props.value} — via _rpd + _bindProp.

The compiler lowers a BARE signal/computed call prop to _rpd(sig) (a REACTIVE_PROP thunk that carries the signal's .direct/._v/.peek, re-targeted — never the branded signal itself, which would misread a plain { value: s } elsewhere) and a template text child that READS a prop to _bindProp(props, "value", node, parent), which binds through the prop's GETTER (the thunk makeReactiveProps installed) and takes _bindText's direct tier when that getter has .direct; any other getter falls back to the tracked polymorphic path. Measured on the dispose-500 ladder before building: with the prop holding the signal instead of a wrapper, the text bind's teardown drops ~31 → ~11µs per 500 rows, about half the residual over Solid. Only the exact bare call takes it — sig() + 1, String(sig()), a shadowed name keep _rp; props.a.b keeps bindPolymorphicText. One pre-existing gap stays open and is not this lever's: const props = mergeProps(p, …) is a plain local to the classifier, so {props.value} is STATIC (_setChild) and never updates — identical on main. Locked by compiler/src/tests/reactive-prop-direct-tier-emit.test.ts + runtime-dom/src/tests/reactive-prop-direct-tier.test.tsx (subscriber census before/after dispose, hydration, VNode upgrade).


Storing () => sig() in a data structure instead of the signal itself — the binding silently drops from the O(1) direct tier to a fully-tracked effect

the compiler's _bindText / _bindDirect take the _d1 direct-updater tier only when the source they receive exposes .direct — i.e. when it IS a signal or a computed. A hand-written () => sig() wrapper satisfies the same () => T type but strips that capability, so the binding falls back to a renderEffect: a tracking frame + positional dep verification on every update, and a hashed Set.delete (~20-25ns) instead of a field write on teardown. Measured on a 500-row list, holding the component and the prop name constant and changing ONLY what the row object's field held, wrapping the signal DOUBLED the hashed removals per teardown (1000 vs 500) — see examples/benchmark/probe-disposecensus.ts. Fix: store the signal (or a computed(() => …) when the value is derived — computed carries .direct too), never a bare arrow, which is the one shape with no direct tier. The nuance that makes this non-obvious: this is about what a FIELD or variable holds, not about the JSX you write. A bare known-signal identifier in a prop (value={sig}) is AUTO-CALLED by the compiler to _rp(() => sig()), so the prop carries the value — you cannot hand a signal down that way, and a consumer written props.value() would throw. A member expression (value={row.value}) is NOT auto-called; it becomes _rp(() => row.value) and forwards whatever the field holds, which is why the fix lives in how the data is built. Verify with transformJSX before assuming either shape — the two emit differently.


Signal-like wrapper callables missing the internal _v field

(enforced by @pyreon/lint rule pyreon/storage-signal-v-forwarding): The compiler's _bindText / _bindDirect fast paths read source._v directly (not source()) for zero-call-overhead initial-value reads on cached signals. If you write a custom callable that wraps a base signal — delegating .direct / .subscribe / .peek to the underlying sig — you MUST also forward _v via getter: Object.defineProperty(wrapper, '_v', { get: () => sig._v, configurable: true }). Without this, the binding initializes the text/attribute to '' (because undefined coerces to empty string) AND every subsequent direct callback reads undefined again — the binding fires, but writes the same empty value every time. The bug is invisible in unit tests that call wrapper() directly (which DOES delegate correctly via the function call) but fires in any consumer of the compiler-emitted fast path. Real-world hit: @pyreon/storage's useStorage / useSessionStorage / useCookie / useMemoryStorage / useIndexedDB all shipped without _v forwarding from inception; SSR rendered <strong>light</strong> correctly but post-hydration the strong went empty and stayed empty even after theme.set('dark') updated localStorage. Reference: packages/fundamentals/storage/src/local.ts:createStorageSignal for the canonical fix shape; _bindText contract documented at packages/core/runtime-dom/src/template.ts:_bindText ("source has .direct() implies source has ._v"). The compiler triggers the fast path for the JSX shape {() => identifier()} where identifier resolves to a callable — including anything that quacks signal-like. Canonical fix — use wrapSignal(base, { set }) from @pyreon/reactivity instead of hand-rolling the facade: it forwards _v / .direct / .peek / .subscribe / .label by construction, so the contract is impossible to forget. @pyreon/storage (all 5 backends) and @pyreon/state-tree (trackedSignal) now use it — and that surfaced the bug class in state-tree: its hand-rolled trackedSignal forwarded neither .direct NOR _v, so a model field bound via {() => model.field()} rendered empty (regression-locked by state-tree/src/tests/tracked-signal-bind-contract.test.ts, bisect-verified). The lint rule remains for any future hand-rolled facade that bypasses the primitive.


[FIXED, 2026-09] Re-reading two-tier subscriber storage AFTER dispatching from it — a promotion mid-notify silently skips a live subscriber.

The _s1/_s store has THREE shapes (_s1 alone = 1 subscriber; _s1 + a FUNCTION in _s = 2 inline; a Set in _s with _s1 === null = 3+), and addSubscriber PROMOTES the two inline slots into a Set the moment a third arrives. createSelector's notifyBucket snapshotted both inline subscribers before dispatching — correct — but then asked "is the second one still subscribed?" with an identity compare against the INLINE tiers only (host._s === s2 || host._s1 === s2). When the first subscriber registered a THIRD on the same key, the promotion moved s2 INTO the Set and nulled _s1, so neither compare matched and s2 was silently skipped despite being live: a row whose class is driven by a selector simply stopped updating. Measured on two inline subscribers, aRuns=2 bRuns=1; already-promoted buckets took the Set branch and were always correct, which is why it hid. v0.51.0 iterated the Set with a size cap and had no hole; the inline fast path (#3350) reintroduced one. Rule: a "still subscribed?" test must be asked of EVERY shape the storage can be in, not the shapes the value could have occupied at entry — a dispatch can MOVE a subscriber between tiers. Use one predicate (isSubscribed) whose typeof s !== 'function' discriminator mirrors removeSubscriber, so the two cannot disagree about what _s holds. The sibling dispatch sites were audited and are correct precisely because they never re-read: signal.ts's _set/_trigger snapshot both slots and enqueue BOTH through the pending queues, and batch.ts's propagateLazyDirty materialises subs before hopping. Companion to "Detecting 'has direct subscribers' by reading signal._d alone" above — same storage, opposite mistake (that one reads too FEW tiers, this one reads them too LATE). Bisect-verified in packages/core/reactivity/src/tests/createSelector-tier-promotion.test.ts (revert → expected 1 to be 2 on the two-inline arm while all three control arms stay green).


Normalizing a prop at component SETUP instead of inside the accessor — the value may be a live GETTER, and the normalizer freezes it

(@pyreon/kinetic show, 2026-09). Every kinetic surface built its visibility accessor as toShowAccessor(props.show) at setup: a show that is already a function passed through, anything else was wrapped as () => value. Correct for a plain boolean, and silently fatal for the shape the compiler actually emits — in member position show={isOpen} lowers to an _rp thunk that makeReactiveProps installs as a GETTER on props, so the single setup read fired it outside any tracking scope and handed the normalizer a frozen boolean. A kinetic('div').preset(fade) component given show={isOpen} then mounted in its hidden state and stayed at opacity: 0 for the element's whole life however the signal moved — with NO throw and the children still mounted (the SSR contract), so the only symptom was invisible content. (State the reproduction in terms of the EXPORTED surface: @pyreon/kinetic ships kinetic + useTransitionState, so the internal <Transition>/<Collapse>/<Stagger> that carry three of the five call sites cannot be imported — an entry that reproduces with an unimportable component teaches a recipe nobody can run.) Rule: a normalizer that turns "value or accessor" into "accessor" must read its source INSIDE the returned accessor, from a live holder (() => normalize(holder.x)), never from a value captured at setup. @pyreon/core's <Show> is the reference — it calls callWhen(props.when) inside its accessor, not beside it. The tell is a helper whose parameter is props.x rather than props: passing the VALUE has already destroyed the thing the helper exists to preserve. And the fix has a scope question the reproduced shape hides: nothing about the compiler's emission is specific to the prop that was reported. transition={sig()} / timeout={sig()} lower to _rp identically, so every sibling prop read at setup off the same getter-bearing holder carries the same freeze — a prop is worth singling out only when there is a reason (here: show is the one whose freeze is INVISIBLE, because the element renders correctly and merely never changes), and that reason belongs in the docblock rather than left implicit. Same family as the descriptor-copy entries below, one level up — those lose reactivity by copying a getter's value between objects, this loses it by normalizing a getter's value into a closure. Detection: 274 existing kinetic specs were green against the broken build, because a hand-written show={() => sig()} (what every test wrote) is exactly the one shape the setup read cannot break. The load-bearing spec feeds the REAL compiler shape — an _rp-branded prop — and keeps the explicit-accessor form beside it as a control. Reference: packages/ui-system/kinetic/src/show-accessor.ts:showAccessorFrom; locks __tests__/show-reactive-prop.test.tsx (happy-dom, all five call sites) + kinetic.browser.test.tsx (real Chromium, computed opacity), bisect-verified (revert to the setup read → expected 0 to be 1 on the browser fade, expected "vi.fn()" to be called 1 times, but got 0 on the enter callbacks).


A sync effect that calls setX((prev) => …) SUBSCRIBES to what it is about to write — an unbounded self-retrigger.

The updater form READS the library's own options/state atom; inside a tracked effect that read is a subscription, so the write re-fires the effect forever (measured: the effect ran until the test's cell-render counter blew past every bound). Rule: in a "sync my reactive inputs INTO the library" effect, track ONLY your own inputs and wrap the library write in untrack. The tracked part is options(); the write is not.


When a per-item signal is meant to be the SOLE subscription, the whole lookup must be untracked — untracking one call is not enough.

Under a fully-reactive library EVERY navigation step is a derived-atom read (getRowModel(), getVisibleCells(), getContext()), so any one left tracked re-subscribes the item to library-wide state and silently restores coarse invalidation (measured: 20 cell re-runs where 2 were correct, with only the first call untracked). Untrack the entire lookup, or accept coarse.


Never compare a user-supplied collection by ARRAY IDENTITY to decide "did this change?"

The idiomatic call site inlines the literal (columns: [...] inside an options function), so the reference churns on every sync and the check reports "changed" every time — coarse-invalidating everything and defeating the fine-grained path for exactly the users who wrote the most natural code. Compare a cheap STRUCTURAL signature instead.


A "nothing changed → invalidate everything" fallback is unsound in a multi-atom graph.

Such an effect can legitimately re-run more than once per user change (different atoms settle separately), and on the later pass the baselines have already been advanced — so the fallback fires on a spurious re-run and coarse-invalidates the whole view. Bump on a POSITIVELY DETECTED change only; a re-run that detects nothing must do nothing.


Implementing a seam's atom WITHOUT matching the reference bindings' DEFAULT compare — "no compare" means Object.is, not "notify unconditionally".

TanStack Store's reference createAtom (the semantics table-core is written against) defaults compare to Object.is and does NOT propagate an equal update. Mapping "no compare" to a bare Pyreon computed (which notifies on every dep change) silently diverged: core creates its per-slice table.atoms[key] with NO compare while their fn reads table.options — invalidated by EVERY options write, data edits included — so each data edit re-notified every slice subscriber with an UNCHANGED value. Combined with a tracked per-row core read (each={() => row.getVisibleCells()} — its memo deps read table.options too), a single-cell edit re-ran all N per-row cells-list accessors (measured 1000-of-1 at N=1000, making the edit 3.0× slower than memoized react-table while the CELL-unit counts showed parity — a class the deterministic count bench structurally cannot see; only wall-clock + a re-run counter on the LOOP, not the cell, caught it). Fixes: equals: compare ?? Object.is in both atom kinds (reference parity), and a fine-grained cells-list accessor (visibleCells) that subscribes to the row signal + the column-geometry slices and untracks the lookup. Rule: when implementing a pluggable-reactivity seam, read the REFERENCE bindings' defaults (compare, laziness, scheduling) and match them — the library is written against those semantics, and a divergence surfaces not as an error but as N× spurious re-runs. Bisect-locked in table/src/tests/reactivity.test.ts (Object.is parity) + fine-grained-cell.test.tsx (the 1000-of-1 count).


Adopting the seam DELETES hand-rolled machinery — check what becomes dead.

The v8 adapter's version counter, whole-TableState structural diff (sameTableState/sliceEqual), and onStateChange interception all existed solely because v8 had no seam; v9 made them obsolete (372 → ~250 lines with MORE capability). When a dependency grows a reactivity seam, the migration is an opportunity to delete, not just to rename.


Reactivity Mistakes