Repository navigation
fix(signals): a new async memo created over a hold lands its first answer into it (#3800) - #3881
Conversation
…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 detectedLatest commit: dfac67d The changes in this PR will be included in the next version bump. This PR includes changesets to release 12 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 |
Size (brotli, eager entry chunk)
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 |
… 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>
Coverage Report for CI Build 37685484746Coverage remained the same at 76.43%Details
Uncovered ChangesNo uncovered changes found. Coverage RegressionsNo coverage regressions found. Coverage Stats
💛 - Coveralls |
Merging this PR will degrade performance by 2.73%
|
| 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)
(#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>
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
countis written to 2 and held by an existing async derivation. After an explicitflush(), a new memo returningPromise.resolve(count())reads the held 2. Its scheduled render effect loggedchild 2while the parent was still atparent 1 1. The same memo returningcount()synchronously stays held until the commit.Root cause
recomputeruns only when the pass returns a value. An async first answer throwsNotReadyError, so the arm never runs.setSignalas mainline commit #0. The child's render effect publishedchild 2beside the held page.The fix
recompute(core/core.ts): a first pass that goes pending under the born-held arm's conditions records the transaction it was born into on its extension (_bornIn). The conditions are: it read a held node, or it is a verdict lane's mount that read a staging of the flush (L2: mount triggered by latest() shows content beside the committed value #3851).handleAsync(core/async.ts): the first answer is staged into that transaction (holdNode) if the transaction is still live (liveTx, a new helper incore/scheduler.ts). The seam's park check reuses the helper.This is the
fix/create-time-holdsencoding (91e474506), ported to currentnextand 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
SPEC-ASYNC-SEMANTICS.md, direction rule: the New async memo reveals a held signal update early #3800 entry ("a first load derived from a hold lands into it") now describes the shipped mechanism and its pins instead of "Pending".SPEC-ASYNC-SEMANTICS.md, A29: the maintainer's dated clarification, quoted, sits next to the fix(signals): seat a first pass in its creator's lane, never a verdict lane (#3835, #3851) #3869 amendment.RULES-INDEX.mdregenerated.Tests
packages/signals/tests/issue-3800-repro.test.tsflush()waits for the hold, thenparent 2 2andchild 2reveal together.flush()controls: the async child stays held; the sync child shows the committedchild 1(A28).fix/create-time-holds: the first load lands into the hold; under a freshLoadingthe fallback shows until the commit; a slower first load lands after the hold as its own commit; under an action's hold, the first load waits for the action and never the reverse.packages/signals/tests/verdict-mount-first-pass-3851.test.ts: "same with an async content memo" (the async twin of L2: mount triggered by latest() shows content beside the committed value #3851; onnextit showedcontent 1besidex = 0).next, 5 of these fail. With the fix: signals 5068 passed, 26 expected fail, 0 failing; solid-js 833 passed; web suites green. No existing pin flips. fix(signals): a Loading mounted under a hold shows its fallback (L2 regression of #3540) #3824'sisPending(view) === falseand fix(signals): seat a first pass in its creator's lane, never a verdict lane (#3835, #3851) #3869's "a boundary mounted by the hold" pins still pass.Size
Local harness against
next(dafad1db3); CI numbers follow in a comment.Size-Exception: approved 2026-10-07 by the maintainer on the condition hello world stays under 10 KB.
Public API changes
None.