fix(signals): an effect's dependencies are the committed frame's until its run applies (#3438) - #3439
Conversation
…l its run applies (solidjs#3438) The effect arm of A30 (solidjs#3410). The compiler emits `{show() ? count() : "hidden"}` as an insert effect, and its pass on the plain flush direct-commits the value and trimmed the `count` edge right there — while the same flush then pended the async memo, became a hold, and stashed the effect's run. The DOM kept the count-derived frame with `count` no longer an input, so the later mainline count write never reached it: `Count: 1` beside `Panel: 0` while `Show` still read `true`. recompute's tail now leaves an effect's dependency tail linked while a run is owed (`_modified`), and runEffect trims it once the run applies — the twin of commitPendingNode's trim for a staged memo pass. The write reaches the effect, which (a mainline reader of the foreign hold) re-derives `Panel: 1` beside `Count: 1`, and the hold's landing reveals `hidden` with `Show: false`. The attribution engine's subscription diff and wide-scope check walk the validated prefix only. +35 B core floor (noted, no bump); three brotli caps ratcheted 0.05 KB with measurements against `next`. Spec A30 extended, rules index regenerated, changeset included. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
🦋 Changeset detectedLatest commit: 2dbb9fe The changes in this PR will be included in the next version bump. This PR includes changesets to release 11 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Co-authored-by: Cursor <cursoragent@cursor.com>
ryansolid
left a comment
There was a problem hiding this comment.
Approved — this is the right fix, and the predicate is the important part.
I had the same diagnosis from the compiled output (the ternary is an insert effect; A30's deferred trim keyed on
_pendingValue, which an effect doesn't have) and was about to gate the deferral onisEffect && activeTransition !== nullat the pass. That would have missed this exact case: the effect's pass runs mainline and the transaction opens later in the same flush, when the render reader ofdelayedShowobserves it pending and the batch is adopted — which is why the run was stashed even though the pass wasn't. "A run is owed" (_modified) withrunEffecttrimming once the run applies is the correct statement of "the frame is the run, not the value slot," and the exact twin ofcommitPendingNode's trim for a staged memo pass. ThecaptureDeps/ wide-scope walk on the validated prefix matches what the fan-in count already did.Resulting shapes, for the record: inline effect →
Count: 1 | Panel: 1at the write,hidden | Show: falseat the landing (a mainline reader of a foreign hold, A15's stale-reader term); memo-wrapped → thecountwrite joins the hold (A29) and reveals at the landing. Both coherent; effects render mainline by design (#3407).One housekeeping commit pushed to the branch:
docs/RULES-INDEX.mdhad been regenerated withoutpnpm format, which strips prettier's table alignment (~360 lines of separator churn that the next regeneration would flip back). Formatted; no other file was affected.— Claude via Cursor
Fixes #3438.
The effect arm of A30 (#3410, landed in #3431).
The gap
The compiler emits the ternary as an insert effect (only
!!show()is memoized; the branch body runs in the effect's compute).setShow(false)is held bydelayedShow. The effect's pass on the plain flush direct-committed its value andtrimStaleDepsdropped thecountedge right there — while the same flush then pended the async memo, became a hold, and stashed the effect's run. The DOM kept the count-derived frame withcountno longer an input, so the later mainlinesetCount(1)never reached it:Count: 1besidePanel: 0whileShowstill readtrue.#3431 covered memos only: a memo always stages
_pendingValue, so its trim was moved tocommitPendingNode. An effect on the plain path writes_valuedirectly and owes a run instead — its frame is the run, not the value slot.Rule
An effect's dependencies are the committed frame's until its run applies.
recompute's tail leaves an effect's dependency tail linked while a run is owed (_modified), andrunEffecttrims it once the run applies (after a clean compute pass, as the commit does) — the twin ofcommitPendingNode's trim for a staged memo pass. A pass that changed nothing owes no run and trims at its tail as before.The
countwrite now reaches the effect. A render effect is a mainline reader of the foreign hold (it is served the committedshow), so it re-derivesPanel: 1besideCount: 1, and the hold's landing revealshiddenwithShow: false:That differs from the
createMemo-wrapped conditional, where the count write joins the hold (A29); both frames are coherent, and the shapes differ because effects render mainline by design. Documented under A30 inSPEC-ASYNC-SEMANTICS.md.The attribution engine's subscription diff (
captureDeps) and wide-scope check walk the validated prefix[_deps.._depsTail]only, so a deferred tail is not reported as a dropped or extra dependency (the__OBSERVE__fan-in count already did this).Verification
@solidjs/signals: 171 files / 1797 tests green, including the newtests/held-conditional-effect.test.ts(pins the frames and the dependency list through the hold) and the existing 2.0.0-rc.8 conditional memo reveals a held signal value early #3408/2.0.0-rc.8 conditional memo disagrees with its inputs during a held branch change #3410 pins.@solidjs/web: 84 files / 770 tests green, including the newtest/conditional-jsx-held-branch-3438.spec.tsx— a jsdom port of the issue's playground (real click, DOM sampled through the hold) plus the memo-wrapped control; fails onnextunder both the native and Babel compilers, passes here.nextbuild (+ createStore+56, hydrating-no-stores +64, CSR +6); the other scenarios are flat or smaller.--checkclean; the one unresolved test id is pre-existing onnext). Changeset included.🤖 Generated with Claude Code