Skip to content

fix(signals): isPending() inside a memo reports a hold whose staged node is unstamped (#3457) - #3465

Merged
ryansolid merged 3 commits into
solidjs:nextfrom
brenelz:fix/ispending-memo-unstamped-hold-3457
Sep 15, 2026
Merged

ryansolid merged 3 commits into
solidjs:nextfrom
brenelz:fix/ispending-memo-unstamped-hold-3457

Conversation

@brenelz

@brenelz brenelz commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Fixes #3457.

The gap

const slow = createMemo(() => delay(1000, count()));
const copy = createMemo(() => count());
const pending = createMemo(() => isPending(copy));
<p>Slow: {slow()}</p>
<p>Direct pending: {String(isPending(copy))}</p>
<p>Memo pending: {String(pending())}</p>

setCount(1) holds the write for a second. Observed on next (jsdom port, 100ms delay):

t=0..80:  Count: 0 | Slow: 0 | Direct pending: true  | Memo pending: false
t=100:    Count: 1 | Slow: 1 | Direct pending: false | Memo pending: false

Expected Memo pending: true beside Direct pending: true for the whole hold.

Traced: slow recomputes first, pends, and queue notification opens the transaction (the ambient batch is adopted; currentBatch becomes the transaction). copy recomputes next, with the transaction already active: queuePendingNode pushes its staged 1 straight into the transaction's pending nodes, but nothing stamps copy._transition on that path (stamping happens at batch adoption and at stash). syncCompanions (#3413) correctly flips copy's pending companion to true, which OPT-dirties pending on the companion's lane.

pending recomputes under that lane (A31) and probes copy. A memo is not a stale reader, so laneReadsCommitted declines and the probe reads the fresh staged 1. recordFreshRead asks heldAwaitingAsync(copy): t resolves to activeTransition, whose _asyncReporters already lists slow as pending, but copy carries no stamp yet and the scan was gated on if (!et) return false. So copy lands in freshReads, the A10 pairing rule mutes the companion's true, and the memo caches false. It is registered in suppressedProbes, but wakeSuppressedProbes only fires when a reporter is added, and slow registered before copy ever recomputed, so nothing corrects it. The direct render effect is stale, reads the committed 0 under the lane, is not a fresh read, and reports true. Hence the disagreement for the whole hold.

The stamp gate was added by #3078 alongside the t = activeTransition fallback, purely to keep the reporter scan's previous reach; it carries no rule of its own. #3413 (companion sync) and #3445 (A31 lane posture) both hold here: the companion flips, the memo runs under its own lane. The failure is in the verdict pairing, not the value read.

Fix

Drop the stamp gate in heldAwaitingAsync so the reporter scan also runs for an unstamped node. This is sound because whenever activeTransition is non-null, currentBatch === activeTransition, so a node with a staged _pendingValue and no stamp necessarily belongs to that transaction: t resolved to the transaction that owns its staged write. The A10 pairing rule is unchanged for what it was ruled for, a LANDED answer awaiting reveal: the scan returns true only while a source in the transaction is still pending with reporters, so a transaction with nothing computing still mutes the verdict for a reader that saw the fresh value, stamped or not. The #3078 action carve-out is untouched.

Trade-off stated plainly: the still-computing carve-out now also applies in the mid-flush window between a transaction's adoption and its stash. The alternative, stamping _transition in queuePendingNode when the batch is a transaction, would touch the scheduler hot path and the core byte floor for the same outcome.

Timeline after the fix (same jsdom port):

t=0..80:  Count: 0 | Slow: 0 | Direct pending: true  | Memo pending: true
t=100:    Count: 1 | Slow: 1 | Direct pending: false | Memo pending: false

Verification

🤖 Generated with Claude Code

…ode is unstamped (solidjs#3457)

A memo wrapping isPending(copy) cached false for a whole hold while a
direct isPending(copy) render-effect probe read true. `slow` pended first
and opened the transaction; `copy` then staged its value straight into the
transaction's batch, unstamped until the flush stashed the hold. The
wrapper memo, recomputing on the companion flip, read copy's fresh staged
value, and heldAwaitingAsync's reporter scan was gated on the missing
`_transition` stamp, so the A10 fresh-read pairing rule muted the verdict
even though the transaction's async source was still computing.

Run the reporter scan for an unstamped node too: with a transaction
active, the ambient batch IS that transaction, so the node's staged write
belongs to the transaction `t` resolved to. The pairing rule still stands
for a landed answer awaiting reveal, and the solidjs#3078 action carve-out is
untouched. Spec note under A10; rules index regenerated; changeset.

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: 6861fa5

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/ispending-memo-unstamped-hold-3457 (6861fa5) with next (5f7da9d)

Open in CodSpeed

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

# Conflicts:
#	packages/signals/docs/RULES-INDEX.md
@ryansolid
ryansolid merged commit 347a5ca 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