Repository navigation
Conversation
🦋 Changeset detectedLatest commit: 7c4c4f1 The changes in this PR will be included in the next version bump. This PR includes changesets to release 13 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)
|
Merging this PR will improve performance by 6.77%
|
| Benchmark | BASE |
HEAD |
Efficiency | |
|---|---|---|---|---|
| ⚡ | build + consume |
740.5 µs | 693.6 µs | +6.77% |
Tip
Curious why performance improved? Comment @codspeedbot explain why performance improved on this PR, or directly use the CodSpeed MCP with your agent.
Comparing feat/create-deferred (7c4c4f1) with next (9f3af34)
… never leads A plain async memo holds: while a refetch is in flight its readers suspend and the write that caused it is held with it, so every commit in a tick waits on the slowest fetch. createDeferred opts one node out. Once it has a committed value, a refetch is served the previous answer: readers keep rendering, the input's write commits now, and the landing commits on its own schedule. The design is packages/signals/docs/create-deferred.md (propositions D1–D9); tests/createDeferred.test.ts pins one test per proposition plus §2.1, §8.1 and §8.2. Ported onto L2 (the hold model, #3774) — the semantics are the PR's, the mechanism is re-implemented against the running core (create-deferred.md §6 records each seam, and what #3710 did on the pre-L2 core): - The clamp is unchanged in spirit: a compute wrapper (src/deferred.ts) re-opens the commit-#0 loading window (`_loading`) per answered pass, so the pass neither throws nor goes STATUS_PENDING — no frame observes a flight, the flush does not park, the write commits with its tick. The wrapper registers an async-shaped result through handleAsync itself (a projection's pattern) and opens a flight only for one still in the air, which closes #3710's "sync-resolved thenable" known edge. - The verdict is an `affects()` mark whose scope is the flight: a count on the node (affects.ts `mark`/`unmark`), pull-derived coverage through `GlobalQueue._marked` (derived stores included, via pullFamily's read of the derive inside the window), pushed to the verdict readers at open and close. The mark drops at the landing ahead of the write (`GlobalQueue._deferredLanded` in asyncWrite), as landStatus clears a plain memo's pending ahead of it; a rejected flight or a dead node closes at the seam (affects.ts `onSeam`, riding `_releaseAmbientMarks`). No companions, no markWalk, no repoll modes, no verdict.ts change. - Never leads (D2) is the seam's own hold decision: the wrapper queues the node as a pending node of the flush it ran in, with nothing staged; a parking flush holds it (holdNode) like any pending node and its landing re-enters the hold (A34 (1)); a committing flush sweeps it as a no-op. commitPendingNode closes the window only for a staged value. - D9: refresh's waiter and until's predicate (signals.ts `watch`) ask the module for an unanswered flight — the predicate through its current deps, hopping a store slot node to its family's derive (`GlobalQueue._slotDerive`, store/store.ts) — and park on it; the flight's close wakes them (`wake` also releases parkers behind an unchanged derivation, which the landing's settle walk cannot reach). - recompute keeps REACTIVE_REASK readable through the pass (the wrapper classifies a quiet re-ask); disposeChildren schedules a seam for a dying loading window (the flight's mark is released there). solid-js: createDeferred hydrates like createMemo through the _hydrateSignalLike adapter slot (as createOptimistic), so hydrating bundles that never call it do not retain it; on the server it is createMemo (D7). Size: +18 B minified in every floor scenario (the landing hook, the re-ask mask, the dispose clause), +48 B in store scenarios (+30 for _slotDerive). Brotli: core floor +14 (7315 → 7329, cap 7330), others −18…+35; eight scenarios exceed caps set 8–14 B above `next`'s sizes by 1–22 B. Caps not raised here (the floor caps are frozen; a Size-Exception is the maintainer's call), exactly as #3710 left them. (cherry picked from commit 06307a4 — every hunk re-resolved against L2; see the design doc's §6) Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
The PR's tests pin the propositions; these pin what the hold model adds around them (tests/createDeferred.test.ts, "L2 pins"): - Loading boundary: content stays through a refetch (no fallback — the node never pends, so A33's collection has nothing to catch) and an `on` re-arm is inert for a deferred source (create-deferred.md 5). - Inside an action: a flight asked by the action's write is held with the action and its landing reveals at the settle; the action never waits on the flight — it settles, the write reveals, the panel follows. - The verdict is display-ahead (ruling 6): a tracked isPending(d) flips to true in the flush that parks on a sibling's flight, re-derives at the held landing (the plain memo's shape — a render effect has no equality gate), and flips back at the reveal. - D9's push: an equal-value landing beneath an unchanged derivation releases until(() => derived()) (the landing's settle walk stops at a derivation that never went pending; the flight's close wakes the parker); until over a derived store built on the node parks on the flight through the family's derive. - A thenable that resolves synchronously is a landing, not a flight: no reader sees it pending (the §8.3 edge of the pre-L2 implementation). - A node disposed mid-flight drops its landing and its flight closes at the next seam; latest(() => d()) serves committed and its own-async verdict is loud (A8). treeshake.test.ts: the core floor sheds deferred.ts; createDeferred retains deferred.ts and affects.ts (the mark channel) and not the verdict layer, lanes, stores, boundaries or map; refresh/until retain only the authoritative walk, not the clamp or the mark channel. web: a parity-harness scenario, "deferred-settled-element" — the createDeferred twin of the async-memo-under-Loading one — renders through the server harness (D7: the memo's chunk shape) and hydrates in both replay modes with the server span claimed; create-deferred-lag.spec.tsx replays its artifact and reads the DOM mid-flight: the adopted answer stays while the first refetch flies, and the landing swaps it in place. INTERNALS-ASYNC-STATE.md §0 gains the deferred module in the L2 module map. Co-authored-by: Claude via Cursor <noreply@cursor.com>
…l to the seam that runs disposeChildren no longer forces a seam for a dying loading window (the -5 B cut). A createDeferred node disposed mid-flight outside a flush keeps its mark until the next seam that runs; its late landing is still dropped by identity and the dead node freezes at its last committed value. The disposal pin is re-titled to that behaviour and asserts both halves (mark persists after the out-of-flush dispose; released by the next write's flush). Docs and the changeset follow (the changeset's stale _deferredSeam mention corrected to _slotDerive / onSeam).
…er a deferred node Reproduced on the PR head and on the port alike: once the downstream plain memo's flight holds the frame, the deferred node is a held node (its landing is staged in that hold), and a mainline write to its input re-passes it — recompute's T4 join parks the whole tick, the outside reader's run included, until the downstream memo lands. The same pass restages the served committed value over the held landing (m1 refetches the old input; the landing is lost until a later flight re-lands it). Pinned live: the decided halves (a write never waits on the deferred's own flight, D1; the downstream memo's hold is A15's; with the downstream memo deferred too, nothing ever waits). Pinned skipped (current behaviour, pending ruling): the reported half, with the two readings recorded in create-deferred.md 8.3. No core change.
…w stays open under a newer question A deferred pass over a held node (a downstream hold staged its landing; a write to an input re-passed it) returns the staging as its result: handleAsync's served committed value, returned as the result, was compared against the staging (INV-11) and restaged over the landing — the hold's readers re-derived against the old input and the landing was lost until the next flight re-landed it. And the seam sweep re-opens a window a commit closed under a question it did not answer (the hold landing commits the older landing while the re-ask is in the air), so isPending stays true while the node's own flight is in flight (D4). Deferred-module only; pinned on the text-memo graph.
… deferred re-pass joins nothing (ruled 2026-10-04) D11. CONFIG_DEFERRED (1 << 19) brands the node (createDeferred sets it); recompute's T4 arm takes neither the join nor the mainline-publish branch for it. A held deferred node — its landing staged by a downstream async memo's hold — re-passed by a mainline write keeps the held landing (the common part, previous commit) and the write's tick stays mainline: the button repaints, the frame's readers of the held flight are stale readers (A15) re-derived at the hold's landing, the new flight's landing re-enters the hold (A34 (1)). Limit, L2-general: a derivation between the held memo and the frame joins by ruling 3 (pinned beside a pure-L2 control). Pins: the ruling on the playground's real graph (render-effect holes), the derivation limit, the pure-L2 control; the user's exact graph as a web spec. Docs: D11 in create-deferred.md §7, §6.4/§6.5, §8.3 rewritten from two readings to the ruling, §10 stop 6; INTERNALS §0; changeset. Floor cost: +12 B minified (the brand check).
06307a4 to
721c547
Compare
Coverage Report for CI Build 38605635948Coverage decreased (-0.09%) to 77.426%Details
Uncovered Changes
Coverage RegressionsNo coverage regressions found. Coverage Stats
💛 - Coveralls |
Resolves signals.ts: the refresh waiter keeps next's guess-error park (#3895) and adds createDeferred's unansweredFlight park (D9). RULES-INDEX.md regenerated. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018fTNh1FgQx2WTF5xoxwML1
… (D11) Ports #3710's signals tests (D1-D11, mizulu's graph), its web and hydration specs and the deferred-settled-element harness scenario, and makes them pass: - D11: an answered deferred node carries CONFIG_LAGS; recompute's T4 arm does not join its held re-pass to the hold, and the wrapper serves the staged landing as the pass's value, so the write commits mainline and the hold keeps the landing it took. - until over a derivation is released by an equal-value landing: the mark's repoll re-runs truth readers too (not through a derived store, whose leaves re-run them at the derive's write). - The lag walk hops a store slot to its family's derive. The disposal pin is rewritten to this PR's behaviour (the mark drops at disposal). Treeshake pins cover the deferred module and the mark core. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S2HrYgWeGJX6hKw7bD5LQN
… (D11) Ports #3710's signals tests (D1-D11, mizulu's graph), its web and hydration specs and the deferred-settled-element harness scenario, and makes them pass: - D11: an answered deferred node carries CONFIG_LAGS; recompute's T4 arm does not join its held re-pass to the hold, and the wrapper serves the staged landing as the pass's value, so the write commits mainline and the hold keeps the landing it took. - until over a derivation is released by an equal-value landing: the mark's repoll re-runs truth readers too (not through a derived store, whose leaves re-run them at the derive's write). - The lag walk hops a store slot to its family's derive. The disposal pin is rewritten to this PR's behaviour (the mark drops at disposal). Treeshake pins cover the deferred module and the mark core. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S2HrYgWeGJX6hKw7bD5LQN
|
Superseded by #4067. It carries this PR's pins (all 36 signals tests, mizulu's graph D11, and the web and hydration specs) on a smaller core footprint (+14 B minified on the floors, against +24 here). The one exception is the disposal pin: in #4067 the mark drops at disposal instead of waiting for the next seam. Generated by Claude Code |
Ported onto L2 (2026-10-04)
The PR was authored against the pre-L2 core. The branch was rebuilt in place on
next(now203ab1a43): the PR's semantics re-implemented on the hold model, nothing pre-L2 revived. The pre-L2 head is kept as tagbackup/pr3710-pre-l2(06307a469). Spec:packages/signals/docs/create-deferred.md(D1–D11, §6 "On L2", §8.3, §10 review brief).The model on L2
deferredCompute(src/deferred.ts) wraps the memo's compute: every answered pass re-opens_loading, registers an async-shaped result throughhandleAsyncitself, and keeps the window open only for a flight still in the air. Readers are served the committed value (handleAsync's serve branch); noNotReadyError, neverSTATUS_PENDING, so no frame observes it and no transaction is blocked by it (D1;blocked()never sees it). A sync-resolved thenable never opens a flight (A10).affects()mark whose scope is the flight.openFlightmarks + wakes the verdict readers; the mark drops inasyncWriteahead of the landing's write (GlobalQueue._deferredLanded, aslandStatusclears a plain memo's pending ahead of it) so the readers the write re-runs read the landing as final;isPending(() => d())follows A19 unchanged (D4). The seam sweep (onSeam, riding_releaseAmbientMarks) closes what no landing reports (a rejected flight, a dead node) and re-opens a window a commit closed under a newer question.openFlight→queuePendingNode); a parking flush holds it (holdNode), its landing re-enters the hold (A34 (1)), a committing flush sweeps it (commitPendingNode's_loadingclose is gated on a staged value). Inside an action the flight is the action's work and never delays it.<Loading>owns only the first load (D5: uninitialized is loading, not pending) and never sees a refetch;<Loading on>is inert for a deferred source. Errors propagate like a plain memo's (D6). SSR/hydration: servercreateDeferred = createMemo(D7); the client hydrates through the memo adapter; a refetch after hydration keeps the DOM (web hydrate spec).until,refresh's waiter, D9) bypass the clamp throughunansweredFlight's dep walk, hopping a store slot to its family derive (GlobalQueue._slotDerive).D11 — the ruling (mizulu's playground report)
Shape:
count→d1 = createDeferred→m1 = createMemo(async), both as{}holes under<Loading>, a button outside readingcount. Clicks were free whiled1was in flight, but frozen fromm1:exectom1:done. On L2:d1's landing re-ranm1, which pended under its reader (A15) and staged the landing with it; a click re-passed the heldd1, andrecompute's T4 "a pass over a held derivation joins" parked the whole tick — and the pass restaged the served value over the landing, som1refetched the old input and the landing was lost until the next flight (5m1runs, twom1:done(0)). Two readings were spiked on evidence: with the join kept and only the restaging fixed, the button still waited untilm1landed; with the re-pass joining nothing, the button and thesignalhole repainted at once, thedeferred/m1holes held as stale readers, andm1ran once per landed input. Ruled for the second, 2026-10-04 — D11: a deferred node's re-ask stages nothing and joins nothing; a held deferred node re-passed by a mainline write keeps its held landing and the write's tick stays mainline; readers of the held flight in that frame are stale readers (A15). Landed as two commits (fix(createDeferred): a re-ask over a held landing keeps it…— deferred-module only;signals(createDeferred): a lagging question is not an answer…—CONFIG_DEFERRED, the T4 arm). Limit, L2-general: a derivation between the held memo and the frame (a memo readingm1()) joins by ruling 3 with no deferred node involved — pinned beside a pure-L2 control.Public API changes
createDeferredfrom@solidjs/signalsandsolid-js(createDeferred(compute, options?)with thecreateMemooverloads, incl.loadingValue). Server: observationallycreateMemo(D7). Client: hydration-aware through the memo adapter.createDeferred:commitPendingNodecloses a loading-window node's_loadingonly when it commits a staged value; a loading-window node committed with nothing staged keeps its window open (before, the commit sweep closed it unconditionally). (Review stop 5.)GlobalQueue._deferredLandedandGlobalQueue._slotDeriveoptional hook slots;affects.tsexportsunmark/onSeamfor the module;CONFIG_DEFERRED = 1 << 19;recomputekeepsREACTIVE_REASKreadable through the pass.createDeferredis pay-for-use: nothing ofdeferred.tsoraffects.tsis retained unless it is called (treeshake pins).Review brief
From
create-deferred.md§10, verbatim.Six places to read first, each with the one question it should answer.
src/core/async.ts:asyncWrite— theGlobalQueue._deferredLanded?.(el)call after the
REACTIVE_DIRTYdrop, ahead oflandStatus. Is droppingthe mark before the landing's write the right ordering, so the verdict
readers that write re-runs read the landing as final (D1/D4) — and is
"after the dirty-drop" right, so a superseded landing never touches the
mark?
src/deferred.ts:openFlight+src/core/scheduler.ts:settle/commitPendingNode— the wrapper callsqueuePendingNode(el)withnothing staged, and
commitPendingNodenow closes_loadingonly under_pendingValue !== NOT_PENDING. Is a pending node with no staged valuea legitimate input to the seam's hold decision ("never leads", D2), and
does the gated close leave any non-deferred loading-window node open that
used to close?
src/affects.ts:mark/unmark/onSeam→_releaseAmbientMarks,read by
src/core/verdict.ts:verdictValue'sGlobalQueue._markedarm. Is reusing the
affects()mark count ascreateDeferred's verdict(scope = the flight; no scope lists, no boundary channel) sound,
including the sweep running inside affects' release hook?
src/store/store.ts:GlobalQueue._slotDerive+src/deferred.ts:unansweredFlight(consumed byuntiland therefreshwaiter insrc/signals.ts). Is hopping a slot node to its family derive thecorrect and only hole in D9's authoritative dep walk, and is it
acceptable that
untilthrowsNotReadyErrorfor an equal-valuederived answer?
src/core/scheduler.ts:commitPendingNode— the one behaviour changereachable without calling
createDeferred: a loading-window nodecommitted with nothing staged keeps its window open (before, the sweep
closed it unconditionally). Can a plain async memo reach
commitPendingNodewith_loadingset and nothing staged, and if so is"window stays open" what A27 wants there?
src/core/core.ts:recompute, the T4 arm (if (tx._lane || el._config & CONFIG_DEFERRED) {} else if (…) joinPassTx(tx) else if (…) publish mainline) andsrc/deferred.ts:deferredCompute'sasync && el._loadingarm (result = el._pendingValueover a held landing) +seam()'s window re-open. The ruling (D11, 2026-10-04): a helddeferred node's re-pass joins nothing and keeps its held landing; is the
brand check in the T4 arm the right and only core seam, and is the
window re-open at the sweep sound against every other path that clears
_loading? Landed as two commits on this branch — the common part(
fix(createDeferred): a re-ask over a held landing keeps it; the window stays open under a newer question, deferred-module only) and the ruling(
signals(createDeferred): a lagging question is not an answer — a held deferred re-pass joins nothing). §8.3 ("A held deferred node re-passedby a mainline write") has the evidence and the L2-general limit;
pins:
tests/createDeferred.test.ts(the common part; "… commitsmainline; the held landing stands (D11)"; the derivation limit; the
pure-L2 control) and
packages/web/test/create-deferred-mizulu-3710.spec.tsx(the user's exact graph).
Also worth a glance:
src/core/core.ts:recomputekeepsREACTIVE_REASKthrough the pass (the wrapper classifies quiet re-asks by it);
src/core/owner.tsis comment-only after the dispose-clause cut.core/footprint (everything else is the module, the store hop, thepublic surface, tests and docs):
src/core/async.ts— the_deferredLandedhook call (4 lines);src/core/constants.ts—CONFIG_DEFERRED = 1 << 19;src/core/core.ts— theREACTIVE_REASKmask inrecompute's pre-passwipe (1 code line); the
CONFIG_DEFERREDtest in the T4 arm (D11);src/core/owner.ts— comment only;src/core/scheduler.ts— twodeclare statichook slots(
_slotDerive,_deferredLanded);commitPendingNode's_loadingclosegated on a staged value.
Tests
next: 4919 | 3 | 2; this PR adds 36 intests/createDeferred.test.ts— the PR's 20 D-pins plus the L2 pins: boundary, action, display-ahead verdict, D9 push incl. derived stores, sync thenable, disposal/latest, the common part, D11 on the playground's real graph, the derivation limit, the pure-L2 control — and 2 treeshake pins).test/create-deferred-mizulu-3710.spec.tsx, the user's exact graph), server 1412 passed | 2 skipped (incl. thedeferred-settled-elementparity scenario), hydrate 278 passed (incl.test/hydration/create-deferred-lag.spec.tsx).pnpm typesclean in signals and solid;rules-index --checkgreen.|| _loadingdispose clause fromdisposeChildren. Consequence: a deferred node disposed mid-flight outside a flush keeps its mark until the next seam that runs (its late landing is still dropped by identity, the dead node freezes at its last committed value). Pin re-titled to that behaviour: "a node disposed mid-flight outside a flush drops its landing and keeps its mark until the next seam that runs". Restoring the clause is +5 B min in the floor.Size
Brotli / minified vs
origin/next(203ab1a43), caps unchanged:The structural cost is the uniform +24 B minified in every client scenario (the
_deferredLandedhook call 9, theREACTIVE_REASKmask 2, the moved_loadingclose 1, theCONFIG_DEFERREDtest in the T4 arm 12) and +54 B where the store is present (+30 for_slotDerive);deferred.tsitself is pay-for-use. Brotli moves −38…+98 B around that. Over cap: core floor 6 B, + createStore 7 B, + isPending/latest 11 B, CSR 39 B, CSR observe 9 B, page base 80 B, page live 23 B. No cap was raised; the caps are left for the maintainer's Size-Exception at review.Original description
Summary
createDeferred: an async memo that may lag the global clock, but never leads it.A plain async memo holds. While a refetch is in flight its readers suspend, and the write that caused the refetch is held with it, so every commit in a tick waits on the slowest fetch in the tick. The original report was independent dashboard panels sharing one selection store, where 20–200 ms panels were held hostage by 3–10 s panels.
createDeferredopts one node out of that coordination:isPendingstill shows the refetch, including through sync memos and derived stores built on the node.<Loading>, andloadingValuecomposes (the node is born committed).<Errored>; the stale value never masks them.refresh(d)anduntil(...)wait for the landed value.untildoes this transitively, so a predicate that reads the node through a derived memo also waits.createMemo.The design is in
packages/signals/docs/create-deferred.md(propositions D1–D9; §6 is the implementation as landed).tests/createDeferred.test.tspins one test per proposition, plus §2.1, §8.1 and §8.2.Public API changes
createDeferredfrom@solidjs/signals, with the same overloads and options ascreateMemo.createDeferredfromsolid-js, both the client and server entries. The client version hydrates likecreateMemo; the server version iscreateMemo.No existing export, option or default changed. The only behaviour change reachable without calling
createDeferred:commitPendingNodenow closes a node's loading window only when a value is staged (before, it closed it unconditionally). No existing test depends on the difference.Implementation
This is a pay-for-use module,
packages/signals/src/core/deferred.ts, following theoptimistic.ts/verdict.tspattern._loading = true) before calling the user's compute. Core then serves the committed value, or parks the node, exactly as it does forloadingValue. Every site that makes an answer observable already closes the window.isPendingverdict uses theaffects()mark channel. An open flight is a mark thatmarkWalkfinds; it is counted inactiveAffectsMarks, and downstream companions are re-polled at flight start and at landing.refreshanduntil(D9) park on an unanswered flight inside their own computes, soread()is untouched. An answer that is staged by a transition counts as answered. That is what letsyield until(...)settle inside the action whose own write triggered the refetch, instead of deadlocking. There are tests for this in both the direct and derived forms, and I confirmed they fail when the staged-answer rule or the transitive walk is removed.What core carries (all of it in the floor):
recompute's pre-pass flag wipe keepsREACTIVE_REASKvisible during the pass, so the wrapper can classify a quiet re-ask (A24). Thefinallymask still clears it.commitPendingNode's window close is gated on a staged value.GlobalQueue._deferredLanded, called with?.fromcommitPendingNodeand fromasyncWrite's direct branch.An earlier prototype put all of this inline in core. That cost +189 B brotli on the floor, against +17 B for this version.
Size
Every size scenario grows by exactly +40 B minified (the two hook calls and the mask). The treeshake floor goes from 26,678 to 26,695 B minified, against a cap of 26,690.
Brotli vs
next, in bytes:The brotli spread around a fixed 40 B minified is compression noise, most likely from the mangled property names shifting between builds.
Caps are not raised in this PR. CI will fail on the core floor, base page, +createStore, +isPending, stores app, CSR and observe-tier scenarios, and on the treeshake floor check. Raising them, and the
Size-Exception:line for the frozen caps, is the maintainer's call.Known edge
A refetch whose thenable resolves synchronously inside
handleAsync(aMockPromise-style value, never a native promise) closes the window at a site with no landing hook. The mark reads closed immediately, butisPendingcompanions downstream of the node are not re-polled until something else re-derives them. This is documented in §8.3; closing it would take a third hook call in the floor.Test plan
packages/signals: full suite passes except the treeshake floor cap (4,793 passed).createDeferred.test.tshas 20 tests.packages/solid: 809 passed.packages/web: 1,078 passed.tsc -p tsconfig.build.jsonis clean for signals and solid.RULES-INDEX.mdhas been regenerated and is current.scripts/sizeagainst a fresh build oforigin/next.