Skip to content

fix(signals): a new async memo created over a hold lands its first answer into it (#3800) - #3881

Merged
ryansolid merged 2 commits into
nextfrom
fix/new-async-memo-held-3800
Oct 7, 2026
Merged

ryansolid merged 2 commits into
nextfrom
fix/new-async-memo-held-3800

Conversation

@ryansolid

Copy link
Copy Markdown
Member

Fixes #3800. Thanks to @GabbeV for the minimal primitives-only repro and the two controls (a synchronous first result, and no flush() before the mount), which isolated the async first-result path.

What happened

count is written to 2 and held by an existing async derivation. After an explicit flush(), a new memo returning Promise.resolve(count()) reads the held 2. Its scheduled render effect logged child 2 while the parent was still at parent 1 1. The same memo returning count() synchronously stays held until the commit.

Root cause

  • A first pass that reads a held node is born held (A29, creation-time form). But the born-held arm in recompute runs only when the pass returns a value. An async first answer throws NotReadyError, so the arm never runs.
  • The seam then treats the node as a loading source, which no transaction holds (the 2.0.0-beta | The curious case of a deadlock #2937 rule, plan §15.2).
  • Its promise landed through a plain setSignal as mainline commit #0. The child's render effect published child 2 beside the held page.

The fix

This is the fix/create-time-holds encoding (91e474506), ported to current next and trimmed.

Maintainer clarification (2026-10-07): the #3869 ruling ("new memos created don't need to be a part of it") covers new memos that don't read async. A new memo that reads the held world is held, whether its first answer is a value or a promise.

Spec

Tests

Size

Local harness against next (dafad1db3); CI numbers follow in a comment.

Scenario Minified Brotli
app: render + one signal (hello world) +142 9878 → 9914 (well under 10,240)
signals: core floor +139 7382 → 7432
signals: + isPending/latest +135 9570 → 9634
signals: + createStore +145 14603 → 14667
other client app/page scenarios +136 to +149 −63 to +100
frames, server scenarios 0 0

Size-Exception: approved 2026-10-07 by the maintainer on the condition hello world stays under 10 KB.

Public API changes

None.

…swer into it (#3800)

A memo created while a transaction holds a value, whose first pass reads
that value and returns a promise, was a loading source the seam does not
hold (plan sec. 15.2), so its answer landed as mainline commit #0 and its
readers showed the held future beside the committed page. The same memo
with a synchronous first answer is born held (A29). The first pass now
records the transaction it was born into under the born-held arm's
conditions (a joined read, or a verdict lane's mount reading a staging),
and its first landing is staged into that transaction while it is live.
The hold never waits for the first load (the direction rule, #3820).

Maintainer clarification (2026-10-07): the #3869 ruling covers new memos
that don't read async; a new memo that reads the held world is held,
async first answer included.

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

changeset-bot Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: dfac67d

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

This PR includes changesets to release 12 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
todos-server-example 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

@github-actions

github-actions Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

Size (brotli, eager entry chunk)

scenario head vs base minified vs base minified vs recorded cap lazy chunks (not counted)
signals: core floor (createSignal/Memo/Effect/Root/flush) 7.43 KB +50 B (+0.7%) +139 B 0 B 7.45 KB ✅
signals: + createStore 14.67 KB +64 B (+0.4%) +145 B 0 B 14.68 KB ✅
signals: + isPending/latest 9.63 KB +64 B (+0.7%) +135 B 0 B 9.65 KB ✅
app: render + one signal (the simple-app floor) 9.91 KB +36 B (+0.4%) +142 B 0 B 9.93 KB ✅
app: hydrating (no stores) with Show/For/Loading/Errored/lazy 17.89 KB +52 B (+0.3%) +136 B 0 B 17.91 KB ✅ lazy-page.js 0.04 KB
app: hydrating + every store primitive family 29.18 KB +100 B (+0.3%) +149 B 0 B 29.19 KB ✅ lazy-page.js 0.04 KB
app: CSR with Show/For/Loading/Errored/lazy 12.95 KB +82 B (+0.6%) +139 B 0 B 12.96 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier (same app on the observe artifacts) 14.52 KB +67 B (+0.5%) +138 B 0 B 14.53 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier + attribution engine enabled 28.88 KB +72 B (+0.2%) +138 B 0 B 28.89 KB ✅ lazy-page.js 0.04 KB
app: compiled floor (one template, one text hole, one delegated click) 10.12 KB +50 B (+0.5%) +143 B 0 B 10.13 KB ✅
app: compiled CSR (JSX todo app: spread/merge/omit, events, class/style, keyed For, Show, Loading + lazy, store) 25.22 KB +35 B (+0.1%) +141 B 0 B 25.24 KB ✅ stats.js 0.18 KB
app: compiled hydrating (the same JSX todo app through hydrate(), compiled hydratable) 31.11 KB +26 B (+0.1%) +140 B 0 B 31.12 KB ✅ stats.js 0.20 KB
frames: eager client consumer (frames client + transport, lazy codec) 11.11 KB 0 B 0 B 0 B 11.13 KB ✅
page: base server components (hydrating + dynamic + frames + sf reference) 33.84 KB −63 B (−0.2%) +146 B +146 B 33.92 KB ✅ assets.js 0.78 KB, bind.js 1.83 KB, decode.js 6.24 KB, lazy-page.js 0.04 KB, regions.js 0.80 KB, trace.js 8.19 KB, wire.js 0.93 KB
page: live server components (base + live/GET + action + isPending/latest) 37.58 KB +66 B (+0.2%) +140 B 0 B 37.59 KB ✅ assets.js 0.78 KB, bind.js 1.83 KB, decode.js 6.24 KB, lazy-page.js 0.04 KB, regions.js 0.80 KB, trace.js 8.19 KB, wire.js 0.93 KB
page: compiled base server components (the base page as JSX: templates with class/style/attributes/events, For/Show; no spread) 35.12 KB +41 B (+0.1%) +146 B 0 B 35.13 KB ✅ assets.js 0.78 KB, bind.js 1.83 KB, decode.js 6.24 KB, regions.js 0.80 KB, sc-comments.js 0.20 KB, trace.js 8.17 KB, wire.js 0.93 KB
page: compiled live server components (the compiled base page + live/GET + action + isPending/latest) 40.65 KB +52 B (+0.1%) +139 B 0 B 40.66 KB ✅ eager (counted): web.js 22.02 KB; assets.js 0.78 KB, bind.js 1.83 KB, decode.js 6.24 KB, regions.js 0.79 KB, sc-comments.js 0.19 KB, trace.js 8.19 KB, wire.js 0.93 KB
page: base + router (base page + @solidjs/router: createRouter, two routes, preload, useNavigate) 46.01 KB +70 B (+0.2%) +141 B 0 B 46.02 KB ✅ assets.js 0.78 KB, bind.js 1.83 KB, decode.js 6.24 KB, lazy-page.js 0.04 KB, regions.js 0.80 KB, server.js 1.02 KB, trace.js 8.19 KB, wire.js 0.93 KB
page: live + router (live page + @solidjs/router: createRouter, two routes, preload, useNavigate) 47.28 KB +80 B (+0.2%) +142 B 0 B 47.29 KB ✅ assets.js 0.78 KB, bind.js 1.84 KB, decode.js 6.24 KB, lazy-page.js 0.04 KB, regions.js 0.81 KB, server.js 1.02 KB, trace.js 8.20 KB, wire.js 0.94 KB
server: floor (getRequestEvent + isServer) 1.33 KB 0 B 0 B 0 B 1.34 KB ✅
server: renderToString (the server-render floor) 20.40 KB 0 B 0 B +4 B 20.42 KB ✅

Bundled with Rolldown (what Vite ships), brotli q11, decimal KB. A scenario fails only when it is over its brotli cap and its minified size is more than 20 B over the minified recorded with the cap; over the cap within that allowance is brotli layout noise and passes with a warning. Caps and their recorded minified in scripts/size/scenarios.js; the floor and page caps in floor-caps.json are frozen (lower only, or Size-Exception: in the PR body). npm run ratchet lowers caps per RC; it never raises one (scripts/size/README.md).

… its hold (#3800)

Raises seventeen caps to CI brotli + 10 B with their recorded minified,
from Size run 37684843282 against next @ dafad1d — the frozen core,
simple-app, hydrating and live-page floors among them. Accepted by the
maintainer 2026-10-07 on the condition hello world stays under 10 KB
(CI: 9,914 B).

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

coveralls commented Oct 7, 2026 •

Copy link
Copy Markdown

Coverage Report for CI Build 37685484746

Coverage remained the same at 76.43%

Details

  • Coverage remained the same as the base build.
  • Patch coverage: No coverable lines changed in this PR.
  • No coverage regressions found.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 1227
Covered Lines: 996
Line Coverage: 81.17%
Relevant Branches: 958
Covered Branches: 674
Branch Coverage: 70.35%
Branches in Coverage %: Yes
Coverage Strength: 28.38 hits per line

💛 - Coveralls

@codspeed

codspeed Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

Merging this PR will degrade performance by 2.73%

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

⚡ 1 improved benchmark
❌ 1 regressed benchmark
✅ 186 untouched benchmarks

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
❌ memo + sync render effect only (reference) 27.7 ms 32.5 ms -14.76%
⚡ createStore setter: delete + set one root key (#3044 overlay) 2 ms 1.8 ms +10.99%

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing fix/new-async-memo-held-3800 (dfac67d) with next (dafad1d)

Open in CodSpeed

@ryansolid
ryansolid merged commit bc51ada into next Oct 7, 2026
7 of 8 checks passed
ryansolid added a commit that referenced this pull request Oct 11, 2026
 (#3977)

* fix(signals): a first load born under a hold holds it (column 2); drop _bornIn

A new async memo whose first pass reads a held value is held at creation,
like its sync twin (A29): its answer is the hold's, and the hold waits for
it. A fresh or re-armed boundary that catches the pass keeps it (#3540):
then the hold does not wait. This replaces #3881's _bornIn, which landed
the first answer into a live hold but never let the hold wait for it (the
direction rule's time half, withdrawn 2026-10-09).

- issue-3800-repro: a slower first load keeps the hold open; the action's
  hold waits for the pages read outside a boundary.
- direction-rule-probe: the three same-tick/in-flush shapes are no longer
  `it.fails` — they assert the wait.

Size (local, Rolldown): minified -22..-24 B on every scenario; core floor
7440 -> 7431 B br, hello world 9935 -> 9927 B br.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVm3wseHt9wRTY8LfPiHmQ

* fix(signals): a probe alone does not hold

A verdict read (`isPending`, `latest`) registers its reader to re-derive at
the landing — the verdict flips there — without making it a holder of the
hold. A render effect holds when its read threw, or when, beside a probe, its
plain read of the flight was served the committed value (REACTIVE_FRAME_READ,
the stale reader; #3442 / A10). #3774 routed the probe's re-run through the
stale reader, which also made it a blocker, against its own stated intent
("a probe alone is neither").

Fixes a probe-only header keeping a same-source `Loading on` re-arm's
fallback off screen (the header's probe held `data`, so the content under
the boundary was served the committed value instead of throwing).

Re-pinned (only probes read the async, so nothing holds):
- optimistic-settle-verdicts #3409: after the revert each indicator clears
  with its own flight (7000 / 8000), as in the optimistic phase.
- question-scoped-pending 3.4: the list is now rendered plainly, as a <For>
  would; that reader is what keeps the hold and the action's mark alive
  through the refetch.

Size (local): +20 B minified on core / hello world, brotli -3 / -27 B.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVm3wseHt9wRTY8LfPiHmQ

* fix(signals): a boundary that has not shown content catches a held reader

A fresh <Loading> (never initialized) keeps a reader held by an outside
transaction on its fallback instead of joining it to the hold (#3540
re-arm shapes, 'content bound' rows now pass).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVm3wseHt9wRTY8LfPiHmQ

* fix(signals): the reader's position decides a born-held memo's catch

A memo's first pass that reads a hold is born held without joining the
tick: a render effect that reads it decides. Under a boundary that has
not shown content the boundary catches that render effect at its staging
(the pass's own status clear dropped the read-time record), so the
fallback shows now and the content at the commit. Outside one, the tick
joins the hold as before. A memo nobody renders holds nothing (#3802
re-pinned: the mount runs on committed values, never without one).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVm3wseHt9wRTY8LfPiHmQ

* fix(signals): isPending of a memo born held reads as its read

A memo born held has no committed value, so no verdict (D-23): inside an
owner, isPending of it joins the hold as a plain read of it does — held
with the change, or caught by a boundary that has not shown content —
and answers for the commit, where it is final. Unowned it is false.
latest() of it is still its pending value.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVm3wseHt9wRTY8LfPiHmQ

* perf(signals): one live-transaction check per seam; share the owned check

The seam resolved its transaction's liveness twice (ambient marks, then
the park decision): once now. verdictValue computes the owned-reader test
once for the tracked arm and the born-held join.

core floor 20359 -> 20330 B minified, hello world 27868 -> 27839,
isPending/latest 27508 -> 27465.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVm3wseHt9wRTY8LfPiHmQ

* fix(signals): judge an optimistic row's echo as reconcile would, not by JSON (#3898)

Redoes #3937's content check. The server's echo of a guessed store row is
a new object, so reference equality makes every echo a correction (and a
mainline until() then waits on the hold, D-31). The guess is confirmed when
reconcile would write nothing into it from the truth: wrappable pairs
descend, leaves compare as its writes do. JSON.stringify differed on key
order, undefined, Dates and cycles; this is key-order free. The check lives
in the optimistic store (GlobalQueue._echoes), so lanes without stores no
longer carry it.

isPending/latest 27465 -> 27338 B minified (9747 -> 9716 brotli); every
store family 29114 -> 29105 brotli.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVm3wseHt9wRTY8LfPiHmQ

* fix(signals): an errored reader is not re-derived at a landing (#3945)

Redoes #3954. A row binding that reads a derived-store field and
isPending is a verdict reader; the landing of the refetch's transaction
re-derived it (reruns), and that later-cycle tracked read retried the
errored store: an unrequested refetch, unbounded if it kept failing.

An errored reader's display is its error (D-39): reruns skip it. #3954's
sticky _errorMeet mark on subscribers is removed; it also stopped a
genuine re-read (the reader's own input changing) from retrying, which a
new test pins.

core floor +7 B minified, createStore -11.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVm3wseHt9wRTY8LfPiHmQ

* refactor(signals): refresh()'s waiter reads authoritatively (#3895)

Redoes #3895's waiter. It read the guessed node's staged truth by hand;
the waiter is now an authoritative reader (until()'s view: the truth
beneath a guess), and keeps only the part refresh adds — a guessed
node's own re-ask parks the waiter, or rejects it with its error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVm3wseHt9wRTY8LfPiHmQ

* test(signals): a verdict beside a plain read shows now in either read order

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVm3wseHt9wRTY8LfPiHmQ

* test(signals): pin #3962 and #3957, fixed by this branch

#3962 fails on next (the #3954 mark) and passes here; #3957's rejection
reaches Errored with or without a separate isPending reader.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVm3wseHt9wRTY8LfPiHmQ

* fix(signals): pass a lane node's committed value as prev (#3955)

A derivation that reads `latest()` or `isPending()` of a signal an in-flight async memo is holding enters the verdict lane with `_x._lane` still `NOT_PENDING`. When that first lane pass is equal to the previous value, nothing writes the slot, so the next pass under the hold received the `NOT_PENDING` marker as `prev` and a `createSignal((prev = []) => [...prev])` threw `prev is not iterable`, halting the reactive system. The pass now falls back to the committed value when the slot is empty, as `compareValue` already does.

* fix: address review findings

The comment above the pass's previous value in `recompute` now says a lane node's lane value is used only when the slot holds one, matching the guarded expression beneath it. The #3955 test now covers `createMemo(fn)` beside `createSignal(fn)`, asserts that the landing after the equal lane passes flushes without throwing and records only arrays, and resets a halted system after each case so each case fails on its own pre-fix. The changeset describes the thrown `prev is not iterable` instead of the internal marker.

* fix(signals): review fixes for #3977

- echoes(): a self-referencing row is a correction, not a stack overflow
  (next's JSON.stringify threw and fell back the same way); pinned.
- Changeset for the branch's behaviour changes.
- Dead lines removed from fresh-boundary-born-held-reader.test.ts.
- it.fails pin: an async memo created inside a fresh boundary over a held
  value shows its answer before the hold commits (D-12; creation position
  still decides in that shape).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVm3wseHt9wRTY8LfPiHmQ

* test(signals): an optimistic signal's echo is judged by its equals

Ruling 2026-10-11: signals don't reconcile, they equality-check — a
server's new array corrects unless the signal's equals says otherwise.
Pinned both ways; changeset notes the change from next's JSON compare.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVm3wseHt9wRTY8LfPiHmQ

* test(web): an isPending guard on a born-held memo never shows enabled early

<input disabled={isPending(data)}> over a memo born held: during the hold
the branch is held (or on its fallback under a fresh Loading), so no
enabled input over an unjudged value is ever seen; at the commit it shows
disabled=false with the committed value (D-23).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVm3wseHt9wRTY8LfPiHmQ

* test(web): drop a redundant shape comparison that failed test-types

Inside the non-direct branch `shape` is already narrowed to
"show" | "show+loading", so `shape !== "direct"` was TS2367 and failed
@solidjs/web#test-types in CI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PDt8Jv6uf5xRDsrux4UwF

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brenley Dueck <brenleydueck@gmail.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