Reactivity Mistakes
Generated from
.agents/rules/anti-patterns.md(the same source as MCPget_anti_patterns). Each entry is a real mistake + its fix; where a detector code is listed, the linter /pyreon doctor/ MCPvalidatecatches it automatically.
Bare signal in JSX text
{count()} → wrap in {() => count()} or let the compiler handle it.
[FIXED, 2026-10] A fixed-dependency fast path (_bind) handed an expression whose dependency set is not provably stable — the conditional-read class (#3782).
The template emitter combined every general reactive attr (disabled={() => pending() && !failed()}) into one _bind(() => …), which tracks ONLY on its first run (re-runs call fn() untracked). Any read the first run did not reach — behind &&/||/??/?:/?., inside a user function that reads conditionally, or through a props getter whose own body does — never subscribes, so the DOM goes stale after the short-circuit flips (shipped since _bind existed; renderEffect re-verifies deps each run, _bind never does). Fix at the choke point, not per shape: hasFixedDeps/has_fixed_deps (jsx.ts + lib.rs, byte-identical verdicts, locked by a native-equivalence block) admits _bind only when every tracked read is UNCONDITIONAL and from a known signal/computed call (or a pure builtin); an arm with no tracked read (a() ? "x" : "y") is still stable; unknown calls, props member reads, prop-derived consts, tagged templates, block/function accessors are never provable and emit a per-expression renderEffect. A false "no" costs a verify-mode re-run; a false "yes" ships a stale UI, so the check is conservative. Rule: any helper that skips re-tracking is only valid for expressions whose dependency set the CALLER can prove constant — "compiler-emitted" is not that proof. Bisect-verified per backend in runtime-dom/src/tests/bind-conditional-deps.test.tsx (compiled through the real transform; 12 specs fail with the classifier bypassed in either backend).
Resetting module-level frame state to a constant instead of restoring it
Tracking collectors, owners and the lifecycle frame nest, so frame exit must restore the value captured at entry, never reset to null.
Reset-to-null at depth 2 silently drops work: a nested
effect()dropped the outer effect's lateronCleanup()calls; a computed's first eval inside an effect left the effect's later reads subscribed but unrecorded (retained afterdispose());runWithHooksdropped everyonMount/onUnmount/onUpdate/onErrorCapturedregistered after a nested child mount.Nesting is not visible in the calling code:
_tpl(html, bindFn)runsbindFnsynchronously, soconst box = <div>{show && <Child/>}</div>runsChild's whole setup inside the parent's.Use the
tracking.tsframe helpers (runCollect/runVerify);runWithHookssavesgetCurrentHooks()and restores it (packages/core/core/src/component.ts).Tests:
reactivity/src/tests/verify-deps.test.ts("frame-restore regressions"),core/src/tests/lifecycle-frame.test.ts,runtime-dom/src/tests/nested-setup-hooks-frame.test.tsx(compiled through the realtransformJSX; vitest's JSX transform never emits_tpl).
Dispatching a computed's direct subscribers during an inline recompute
An inline write-time recompute may mark dirty and propagate, but direct (_d1/_d) subscribers must fire in the batch drain.
Inside
batch(() => { a.set(); b.set() })the value is torn between writes, so an inline dispatch fires on half-updated values ([12, 30]instead of[30]) and a torn eval that throws reports a phantom error.computed.ts:propagateEagerChangedefers the dispatch viaenqueuePendingNotification; the_dirtyguard keeps it idempotent.The write-time dirty cascade must stay depth-bounded:
batch.tsrecurses while_cascadeDepthis belowMAX_CASCADE_RECURSION, then switches to an explicit stack. An overflow caught downstream would otherwise clear_dirtywith a stale value (a silent lost update).Test:
reactivity/src/tests/batch-glitch-freedom.test.ts(includes a 10,001-deep chain).
Trusting a pulled dep's cache during a subscription-order drain
Tier-1 drains in subscription order, not topological order, so a queued evaluator must never read a dep that was queued but not marked dirty.
Dirty-mark every computed inline during the write's notify phase, so a pull-read evaluates a dirty dep in place.
Clear an entry's queue-membership flag before running it, so a re-notify after its visit re-queues it instead of being deduped away. A DAG settles in at most depth sweeps.
The old direct inline eager path was faster because it was wrong (torn and stale); do not chase that speed.
Code:
computed.ts:createComputed(the read's dirty branch is the single evaluator) +batch.ts(enqueueEagerRefresh,recomputeQueue). Test:reactivity/src/tests/tier1-topo-staleness.test.ts.
Stale closures
signal.peek() captured in long-lived closures loses reactivity.
An effect that reads a counter it also writes
The write re-queues the effect, and the batch re-runs it up to MAX_PASSES (32) times before dropping it with only a generic MAX_PASSES console warning.
Write a counter that exists for other readers with
.peek():version.set(version.peek() + 1).The symptom may not look like a loop: in
packages/fundamentals/charts/src/engine/canvas-host.tsxeach re-run calleddraw(), which cancelled the tween the first run started, so the animation snapped.
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.
One version counter for both content and selection events
When wrapping an imperative engine (ProseMirror/TipTap, CodeMirror) in signals, a single counter bumped by both update and selection events re-evaluates every content computed on each cursor move.
Split by event:
docVersion(content) andselectionVersion; content computeds read onlydocVersion.Better: derive content values from the document signal, not the engine.
@pyreon/rich-text'scharacterCount/wordCount/isEmptywalk thebaseJsonsignal, so they work before mount too.Code:
packages/fundamentals/rich-text/src/editor.ts. Tests:rich-text.browser.test.tsx("a pure selection move does NOT re-run content computeds"),rich-text.test.ts.
Ternary short-circuit hides a signal read
In {() => touched() ? error() ?? '' : ''}, error() is never read while touched is false, so the accessor never subscribes to it.
If the validator sets
errorin the same batch that flipstouched, the error never renders. Same forcond && sig().Fix: read both first:
{() => { const t = touched(); const e = error(); return t ? e ?? '' : '' }}.This is normal fine-grained reactivity (Solid, Preact signals, MobX behave the same). Not statically detectable without false positives. See
docs/src/content/docs/reactivity-rules.md"Conditional Reads Hide Tracking".
Dropping a schema error whose key matches no field
Code that maps a validator's keyed errors onto a fixed field set must route unmatched keys, never drop them. "Matched no field" means invalid.
Adapters flatten nested issues to dot-path keys (
"address.city") and whole-form errors to"".packages/fundamentals/form/src/use-form.ts:matchSchemaErrorForField(schemaErrors, name, fieldNames)routes to the most specific registered field (an exact leaf field wins over its ancestor object field);orphanSchemaErrorKeysmarks leftover keys as a form-levelsubmitError. Both the submitvalidate()and blur paths use them.Orphan detection must use the same match logic as routing (exact field,
nearestAncestorField), or it flags already-routed leaf errors.A declarative schema (Standard Schema or
@pyreon/validationadapter) over a form with dot-path fields receives the nested value shape (getSchemaInputvianestValues); a plainSchemaValidateFnalways gets the flat values.values()/onSubmitstay flat (dot-path keys);nestValues/flattenValuesconvert. Limitation: types are flat, so a nested declarative schema needs anas nevercast.Tests:
form/src/tests/schema-error-routing.test.tsx,nested-paths.test.tsx,nested-schema-split.test.tsx,path.test.ts(includes a__proto__pollution guard).
Accessor interpolated UNCALLED in a template literal
`${itemWidth}%` with itemWidth = computed(...) renders the function source. The compiler's auto-call covers JSX only, not template literals.
Fix:
`${itemWidth()}%`, built inside a tracking scope if it must update.The detector fires only on tracked bindings (
signal/computed, or auseX()result the file also calls asx()); it skips tagged templates (css/styledtake functions) and names re-declared by a non-accessor binding.
Detected by: accessor-uncalled-in-template — surfaced by @pyreon/lint / pyreon doctor / MCP validate.
Accessor used BARE as an if/ternary condition (outside JSX)
A function is always truthy, so if (!has) is dead code.
Fix:
if (!has()). The component body runs once, so render-time branching belongs in<Show when={() => has()}>or a returned accessor.Inside JSX the compiler auto-calls known signals, so the detector fires outside JSX only. It skips nullable hook results (
if (!router)),typeof x === 'function',x == null, property access, and statements that also call the name.
Detected by: accessor-uncalled-in-condition — surfaced by @pyreon/lint / pyreon doctor / MCP validate.
Destructuring props
(parameter shape) [detector: props-destructured-body] (body shape): ({ state }) => … or const { state } = props reads the prop getters once, so the locals never update.
Use
props.stateinside a reactive scope, orsplitProps(props, ['state']).detectPropsDestructured/detectPropsDestructuredBodyinpackages/core/compiler/src/pyreon-intercept.ts. The body detector covers PascalCase components and a returned(props) => …HOC arrow (isReturnedPropsComponent); it only flags= propsat the component body's top scope (nested functions re-readpropsand are fine).Not flagged:
const { x } = props.nested, destructuring another object, destructuring insideonMount. The Reactivity Lens (analyzeReactivity, LSP inlay hints via@pyreon/lint --lsp) showsstaticon such reads.
Detected by: props-destructured — surfaced by @pyreon/lint / pyreon doctor / MCP validate.
JSX spread on a component copies getter props by value
An object spread fires every getter, so <Comp {...source}> would freeze reactive props.
The compiler emits
<Comp {..._wrapSpread(source)}>(compiler/src/jsx.ts:handleJsxSpreadAttribute+native/src/lib.rs:handle_jsx_spread_attribute)._wrapSpread(core/src/props.ts) re-brands getters as_rpthunks viaReflect.ownKeys; getter-free sources pass through unchanged. DOM-element spreads use_applyPropsinstead.Framework packages are not compiled by Pyreon's compiler, so a JSX spread there is a plain object spread; use
h(Comp, mergeProps(a, b)).
Wrappers that copy props by value
result[key] = source[key], {...a, ...b}, Object.assign and rest-destructuring all fire getters and freeze reactive props.
Copy descriptors: use
mergeProps/splitProps/removeUndefinedPropsfrom@pyreon/core, orObject.getOwnPropertyDescriptors+Object.defineProperty. Never hand-roll a copy loop.Write companion keys (
ref, overrides) withObject.defineProperty; plain assignment to a getter-only key is a no-op (sloppy) or throws (strict).Fixed instances:
rocketstyle(utils/attrs.ts),styler/src/forward.ts:buildProps,ui-core/src/utils.ts:omit/pick,kinetic/src/kinetic/createKineticComponent.tsx(splitPropswithKINETIC_KEYS; renderers passmergeProps(htmlProps, { ref, style })). Test:kinetic/src/__tests__/kinetic.browser.test.tsx.The same freeze hides in other shapes (fixed in
@pyreon/elements): body/parameter destructures; render-prop objects built with eager reads (pass_rp(() => active())soaria-expandedstays live without re-rendering the trigger); styling bundles computed at setup (elements/src/utils.ts:hasGetterPropsswitches to an accessor bundle thatstyler/src/styled.tsxtreats as a reactive axis, bypassing class caches and CPSE viaisReactiveEl/bypassElCacheAndCpse); slot-existence checks at setup (run the body in an accessor when slots are getters).A reactive list body must not return fully keyed vnode arrays built from plain props:
mountKeyedListnever re-renders surviving keys, so the rows freeze.Tests:
elements/src/__tests__/reactive-prop-class.test.tsx,reactive-prop-class-sweep.browser.test.tsx.
Fixing one frozen prop when every prop has the same shape
_rp is not prop-specific, so when one setup-read prop freezes, audit every prop read off the same holder.
Separate live from tracked. In
@pyreon/kinetic,showis the state machine's input and is read tracked (showAccessorFrom); every other prop is read live but untracked (readLive/readCallbacks/readLiveValueinkinetic/src/live-prop.ts).watch(source, cb)runscbinside the effect's tracking scope, so a tracked config read would restart the animation whenever an easing string changes.readLivemust not auto-call functions: a callback prop's value is a function.Decide per prop and comment the reason at the site:
appearis a first-mount question; a staggerintervalis baked into resolved child styles and stays a plain read.Tests:
kinetic/src/__tests__/live-props.test.tsx,kinetic.browser.test.tsx.
Resolving a children ?? content ?? label fallback at setup
The ?? fires each getter once, so <Text label={sig()} /> never updates.
Pass an accessor:
children: () => own.children ?? own.content ?? own.label.A JSX-child accessor (
<Comp>{sig()}</Comp>) is unaffected; this is about getter-valued props.A unit test asserting
result.props.childrenequals the resolved value encodes the broken shape; assertprops.children()and flip the signal in a real mount.Code:
elements/src/Text/component.tsx(matchesElement/component.tsx:getChildren). Test:elements/src/__tests__/reactive-prop-through-element.browser.test.tsx.
Checking subscribers by reading _d or _s alone
Both subscriber channels are tiered, so a one-field presence check misses single-subscriber signals.
Direct channel:
_d1(inline) then_d(Set). Tracking channel:_s1(inline), then_sholding a second inline subscriber as a function, then a Set in_s(with_s1 === null) for 3+.Use
_hasSubscribers(sig)/_tierCountfrom@pyreon/reactivity; inside reactivity useaddSubscriber/removeSubscriberfromtracking.ts. Never call.size/.delete/iterate on_sdirectly.addSubscribermust stay idempotent, and code adding to the tiers must promote inline occupants first, or a subscriber is stranded.@pyreon/storewires field detectors on the firstapi.subscribe(), after components already tracked the field (test:store/src/tests/subscribe-after-tracked-read.test.ts).Fixed readers:
solid-compat'ssweepUnusedSignals, the devtools graph,createSelector's sweep.
Component props on the direct subscriber tier
<Row value={sig()} /> read as {props.value} binds without a tracking effect.
The compiler lowers a bare signal/computed call prop to
_rpd(sig)(aREACTIVE_PROPthunk carrying.direct/._v/.peek) and a template text child reading a prop to_bindProp(props, "value", node, parent), which takes_bindText's direct tier when the getter has.direct, else the tracked path.Only the exact bare call qualifies;
sig() + 1,String(sig()), a shadowed name keep_rp, andprops.a.bkeepsbindPolymorphicText.Limitation:
const props = mergeProps(p, …)is a plain local to the classifier, so{props.value}is static and never updates.Tests:
compiler/src/tests/reactive-prop-direct-tier-emit.test.ts,runtime-dom/src/tests/reactive-prop-direct-tier.test.tsx.
Storing () => sig() instead of the signal
_bindText/_bindDirect use the O(1) direct tier only when the source has .direct (a signal or computed). A bare arrow falls back to a renderEffect with dep verification and a hashed Set.delete on teardown.
Store the signal, or a
computed(() => …)for derived values.This is about what a field holds:
value={sig}is auto-called to_rp(() => sig())(the prop carries the value), whilevalue={row.value}forwards whatever the field holds. Check withtransformJSX. Probe:examples/benchmark/probe-disposecensus.ts.
Signal-like wrapper callables missing the internal _v field
(enforced by pyreon/storage-signal-v-forwarding): _bindText/_bindDirect read source._v directly, so a wrapper that forwards .direct/.peek/.subscribe but not _v renders '' forever.
Use
wrapSignal(base, { set })from@pyreon/reactivity; it forwards_v,.direct,.peek,.subscribe,.label.@pyreon/storage(createStorageSignal) and@pyreon/state-tree(trackedSignal) use it.Calling
wrapper()in a unit test does not exercise the fast path. Test:state-tree/src/tests/tracked-signal-bind-contract.test.ts.
Re-reading tiered subscriber storage after dispatching
A dispatched subscriber can add a third subscriber, promoting the inline slots into a Set, so an identity check against the inline slots skips a live subscriber.
Snapshot the subscribers, then ask "still subscribed?" with a predicate covering every shape (
createSelector.ts:isSubscribed, whosetypeof s !== 'function'discriminator mirrorsremoveSubscriber).signal.ts's_set/_triggerandbatch.ts:propagateLazyDirtynever re-read, so they are safe. Test:reactivity/src/tests/createSelector-tier-promotion.test.ts.
Normalizing a prop to an accessor at setup
toAccessor(props.show) reads the getter once and wraps a frozen value.
Read inside the returned accessor from the live holder:
() => normalize(holder.x), as<Show>does withcallWhen(props.when). A helper that takesprops.xinstead ofpropshas already lost the getter.show={isOpen}lowers to an_rpgetter; tests that only passshow={() => sig()}cannot see this. Test with an_rp-branded prop plus the accessor form as control.Code:
kinetic/src/show-accessor.ts:showAccessorFrom. Tests:kinetic/src/__tests__/show-reactive-prop.test.tsx,kinetic.browser.test.tsx.
A Plain Mode line the compiler cannot give its apparent meaning
Mutating a property of SHALLOW plain state (state.raw / a non-literal initializer), assigning to a derived value or a prop, a signalOf(x) whose x is not a plain binding. The pre-pass compiles these but only WARNS in the Vite terminal, and the running app is silently wrong (a mutation that notifies nobody). Fix: replace shallow state wholesale (cfg = { ...cfg, open: true }) or use a literal initializer for deep state; lift writes to the owner. The same warnings surface in the editor via the pyreon/plain-mode-footgun lint rule and in validate / pyreon check via this detector — all three read the pre-pass itself, so they cannot disagree with the build. Plain files are also covered by the classic reactivity lint rules that opt into meta.plainLowered (they run on the file's compiled form).
Detected by: plain-mode — surfaced by @pyreon/lint / pyreon doctor / MCP validate.
A sync effect that writes with setX((prev) => …)
The updater reads the library's own atom, so the effect subscribes to what it writes and re-runs forever. Track only your own inputs (options()) and wrap the library write in untrack.
Partially untracked per-item lookups
Every navigation step in a reactive library (getRowModel(), getVisibleCells(), getContext()) is an atom read. If the per-item signal should be the only subscription, untrack the whole lookup, not just the first call.
Comparing user collections by array identity
Call sites inline literals (columns: [...]), so identity changes on every sync and everything invalidates. Compare a cheap structural signature.
"Nothing changed → invalidate everything" fallbacks
Such effects re-run more than once per change as atoms settle, and later passes see advanced baselines. Invalidate only on a detected change; a re-run that detects nothing does nothing.
Not matching the reference bindings' default compare
Read the reference bindings' defaults (compare, laziness, scheduling) and match them.
TanStack Store's
createAtomdefaultscomparetoObject.is; the adapter passescomputed(fn, { equals: options?.compare ?? Object.is }). Without it, every options write re-notified every slice subscriber with an unchanged value.Per-row cell lists go through the fine-grained
visibleCellsaccessor (row signal + column-geometry slices, lookup untracked); a trackedeach={() => row.getVisibleCells()}re-runs every row on a single-cell edit.Count re-runs of the loop accessor, not only cells. Tests:
table/src/tests/reactivity.test.ts,fine-grained-cell.test.tsx.
Keeping hand-rolled machinery after adopting the seam
When a dependency gains a reactivity seam, delete the workarounds it replaces (the table adapter dropped its version counter, whole-state diff and onStateChange interception).