Skip to content

fix(signals): an effect's dependencies are the committed frame's until its run applies (#3438) - #3439

Merged
ryansolid merged 2 commits into
solidjs:nextfrom
brenelz:fix/held-conditional-effect-3438
Sep 14, 2026
Merged

ryansolid merged 2 commits into
solidjs:nextfrom
brenelz:fix/held-conditional-effect-3438

Conversation

@brenelz

@brenelz brenelz commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Fixes #3438.

The effect arm of A30 (#3410, landed in #3431).

The gap

const delayedShow = createMemo(() => delay(1000, show()));   // show write → held
<p>Panel: {show() ? count() : "hidden"}</p>                   // an insert effect

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 by delayedShow. The effect's pass on the plain flush direct-committed its value and trimStaleDeps dropped 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 setCount(1) never reached it: Count: 1 beside Panel: 0 while Show still read true.

#3431 covered memos only: a memo always stages _pendingValue, so its trim was moved to commitPendingNode. An effect on the plain path writes _value directly 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), and runEffect trims it once the run applies (after a clean compute pass, as the commit does) — the twin of commitPendingNode's trim for a staged memo pass. A pass that changed nothing owes no run and trims at its tail as before.

The count write now reaches the effect. A render effect is a mainline reader of the foreign hold (it is served the committed show), so it re-derives Panel: 1 beside Count: 1, and the hold's landing reveals hidden with Show: false:

2500: Count: 1 | Panel: 1
3000: Delayed: false | Panel: hidden | Show: 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 in SPEC-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 new tests/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 new test/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 on next under both the native and Babel compilers, passes here.
  • Core floor 23,318 → 23,353 (+35 B, noted, no bump). Three brotli caps ratcheted by 0.05 KB with measurements against a pristine next build (+ createStore +56, hydrating-no-stores +64, CSR +6); the other scenarios are flat or smaller.
  • Rules index regenerated (--check clean; the one unresolved test id is pre-existing on next). Changeset included.

🤖 Generated with Claude Code

…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-bot

changeset-bot Bot commented Sep 14, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 2dbb9fe

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 11 packages
Name Type
@solidjs/signals Patch
test-integration Patch
@solidjs/web Patch
@solidjs/babel-plugin Patch
@solidjs/compiler Patch
@solidjs/diagnostics Patch
@solidjs/element Patch
@solidjs/h Patch
@solidjs/html Patch
solid-js Patch
@solidjs/universal Patch

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

@codspeed

codspeed Bot commented Sep 14, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 160 untouched benchmarks


Comparing brenelz:fix/held-conditional-effect-3438 (2dbb9fe) with next (c452850)

Open in CodSpeed

Co-authored-by: Cursor <cursoragent@cursor.com>

@ryansolid ryansolid left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 on isEffect && activeTransition !== null at 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 of delayedShow observes 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) with runEffect trimming once the run applies is the correct statement of "the frame is the run, not the value slot," and the exact twin of commitPendingNode's trim for a staged memo pass. The captureDeps / 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: 1 at the write, hidden | Show: false at the landing (a mainline reader of a foreign hold, A15's stale-reader term); memo-wrapped → the count write 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.md had been regenerated without pnpm 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants