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").


[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


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


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 samples the accessor before the tracked run). 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).


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). Why no exported helper today: solid-compat is the only known consumer; cost-benefit doesn't justify a new internal API surface yet. If a second consumer surfaces, extract _hasDirectSubscribers(sig) from @pyreon/reactivity and migrate both. Until then, any code reading _d MUST also check _d1. Reference: packages/core/reactivity/src/signal.ts interface SignalFn._d1 / _d; the canonical fix is in packages/tools/solid-compat/src/index.ts:894-899.


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.


Reactivity Mistakes