Skip to content

fix(signals): an async memo's held landing keeps the committed frame's dependencies (#3461) - #3464

Merged
ryansolid merged 1 commit into
solidjs:nextfrom
brenelz:fix/async-landing-deps-3461
Sep 15, 2026
Merged

ryansolid merged 1 commit into
solidjs:nextfrom
brenelz:fix/async-landing-deps-3461

Conversation

@brenelz

@brenelz brenelz commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Fixes #3461.

The gap

const [a, setA] = createSignal(0);
const [b, setB] = createSignal(0);
const slow = createMemo(() => delay(2000, b()));
const selected = createMemo(async () => (b() ? b() : a()));
// click: setB(1); await delay(500); setA(1);
<p>A: {a()}</p> <p>B: {b()}</p> <p>Slow: {slow()}</p> <p>Selected: {selected()}</p>

setB(1) is held by slow. Half a second later setA(1) publishes alone: A: 1 | B: 0 | Slow: 0 | Selected: 0 for the rest of the hold, and Selected no longer agrees with the inputs on screen (b() is 0, so it should read a()). At 2000 everything settles to 1. With selected a plain createMemo the a write is held with the transaction and the four values reveal as one frame.

Traced: selected's held pass reads only b (throws NotReady at the flight's registration, keeping its full dependency list with _depsTail at b). Its flight lands in a microtask into the hold, and asyncWrite called trimStaleDeps unconditionally, before the write, so the selected -> a edge was gone even though setSignal then staged the landed value under the transaction. The committed Selected: 0 still derived from a, but the mainline a write no longer reached selected, never entered its hold through the stamp, and committed beside the stale derivation. A30 (#3410) already rules this for a sync pass (recompute trims only when the pass published, commitPendingNode trims a staged pass) and for effects (#3438); the async landing predates the rule (the trim is from the initial import) and was the missing arm.

Fix

The landing follows A30's gate: asyncWrite trims after the write and only when the landing published (_pendingValue === NOT_PENDING, an equal-value or lane landing); a transition-held landing leaves the tail for commitPendingNode, exactly as a staged sync pass does. One call moved:

if (el._pendingValue === NOT_PENDING) {
  el._loading = false;
  if (wasReask) el._x!._reask = false;
  trimStaleDeps(el);
}

Now the a write reaches selected, its stamped recompute enters the hold, and the frame is 5000: A: 1 | B: 1 | Selected: 1 | Slow: 1, identical to the sync control.

Trade-offs, stated plainly: a plain (unheld) landing's trim moves from the landing to the same flush's commit, A30's stated cost model. In the #3373 shape (a landing held while another input is still pending, _error set) the stale tail lives until the next clean pass or landing, which is over-subscription only, never a missed wake.

Side finding, not fixed here: the equal-value variant selected = async () => (b() ? 0 : a()) still tears the same way in both the sync and the async shape, because A30 lets a pass that changed nothing trim at its tail (a landing equal to the committed value published nothing new). This PR mirrors that carve-out deliberately; closing it is a rule decision and is noted in the A30 paragraph.

Verification

🤖 Generated with Claude Code

…s dependencies (solidjs#3461)

`selected = createMemo(async () => (b() ? b() : a()))` with `b` held by a
slow flight: selected's held pass read only `b`, and asyncWrite trimmed the
dependency tail unconditionally before the write, so the `selected -> a`
edge was gone even though the landed value was then staged under the hold.
The committed `Selected: 0` still derived from `a`, but the mainline `a`
write no longer reached selected or entered its hold through the stamp, and
committed beside the stale derivation: `A: 1 | B: 0 | Selected: 0`.

The landing now follows A30's gate (the arm `recompute` and
`commitPendingNode` already implement for sync passes and effects): trim
after the write, only when the landing published (`_pendingValue ===
NOT_PENDING`); a transition-held landing leaves the tail for its commit. The
`a` write reaches selected, joins the hold, and the four values reveal as
one frame, identical to the sync control.

Spec: A30 gains the landing arm; rules index regenerated. Core floor flat
(23,752); brotli core-floor cap ratcheted 8.85 -> 8.90 KB for +2 B of
noise. Changeset included.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Sep 15, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: c24e38e

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 15, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 172 untouched benchmarks


Comparing brenelz:fix/async-landing-deps-3461 (c24e38e) with next (a8a8949)

Open in CodSpeed

@ryansolid
ryansolid merged commit 0da94f9 into solidjs:next Sep 15, 2026
7 checks passed
ryansolid added a commit to brenelz/solid that referenced this pull request Sep 15, 2026
…reset walk on top of solidjs#3464/solidjs#3465/solidjs#3466

Measured at 28861 B against next's 28815 (+46). The PR alone sat 2 B
under against the pre-solidjs#3464 next; the three fixes that landed meanwhile
used the room.

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
ryansolid added a commit that referenced this pull request Sep 15, 2026
…s still wait on (#3459) (#3467)

* fix(signals): a Loading `on` reset collects what its forwarded readers still wait on (#3459)

The `on` reset cleared the boundary's sources and rebuilt them from the
pending notifications that followed. A reader already pending from an
earlier write never re-notifies (status propagation dedupes on its
`_pendingSources`), so with `fast` and `slow` read from sibling effects
only the fresh flight was collected; the wake committed the held write
(#3375: a reader behind a collecting boundary holds nothing), `fast`
landed a microtask later, and the boundary revealed `B: 1 | Fast: 1 |
Slow: 0` with `slow` still in the air.

The reset now harvests, from every live transaction's `_asyncReporters`
(INV-3, the one record of a forwarded reader), the sources of each
reporter it routes (`_holds`: under this queue with no collecting
pending-type boundary between, the `reporterBlocksSource` test) plus that
reporter's `_pendingSources`, and flips to the fallback when it found
any. The hold the ruling takes off the lane lands on the boundary:
`B: 1 | Loading`, then `Fast: 1 | Slow: 1` together. `scheduler.ts`
exports `transitions` for the walk; no scheduling logic changes.

Core floor unchanged (23,752). Docs bullet, rules index, changeset.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* chore(size): hydrating+stores cap 28.85 -> 28.90 KB — #3459's reset walk on top of #3464/#3465/#3466

Measured at 28861 B against next's 28815 (+46). The PR alone sat 2 B
under against the pre-#3464 next; the three fixes that landed meanwhile
used the room.

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

* docs(signals): regenerate rules index after the INTERNALS merge resolution

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

---------

Co-authored-by: Brenley Dueck <brenleydueck@Brenleys-Mac-mini.local>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Ryan Carniato <ryansolid@gmail.com>
Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
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