Skip to content

feat(signals,solid): createDeferred — an async memo that may lag, but never leads - #3710

Closed
ryansolid wants to merge 8 commits into
nextfrom
feat/create-deferred
Closed

ryansolid wants to merge 8 commits into
nextfrom
feat/create-deferred

Conversation

@ryansolid

@ryansolid ryansolid commented Sep 29, 2026 •

Copy link
Copy Markdown
Member

Ported onto L2 (2026-10-04)

The PR was authored against the pre-L2 core. The branch was rebuilt in place on next (now 203ab1a43): the PR's semantics re-implemented on the hold model, nothing pre-L2 revived. The pre-L2 head is kept as tag backup/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

  • Clamp = the commit-#0 loading window. deferredCompute (src/deferred.ts) wraps the memo's compute: every answered pass re-opens _loading, registers an async-shaped result through handleAsync itself, and keeps the window open only for a flight still in the air. Readers are served the committed value (handleAsync's serve branch); no NotReadyError, never STATUS_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).
  • Verdict = an affects() mark whose scope is the flight. openFlight marks + wakes the verdict readers; the mark drops in asyncWrite ahead of the landing's write (GlobalQueue._deferredLanded, as landStatus clears 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.
  • "Never leads" = the seam's own hold decision. A flight asked inside a flush is queued as a pending node of that flush with nothing staged (openFlight → queuePendingNode); a parking flush holds it (holdNode), its landing re-enters the hold (A34 (1)), a committing flush sweeps it (commitPendingNode's _loading close is gated on a staged value). Inside an action the flight is the action's work and never delays it.
  • Boundary. <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: server createDeferred = createMemo (D7); the client hydrates through the memo adapter; a refetch after hydration keeps the DOM (web hydrate spec).
  • Authoritative readers (until, refresh's waiter, D9) bypass the clamp through unansweredFlight'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 reading count. Clicks were free while d1 was in flight, but frozen from m1:exec to m1:done. On L2: d1's landing re-ran m1, which pended under its reader (A15) and staged the landing with it; a click re-passed the held d1, and recompute's T4 "a pass over a held derivation joins" parked the whole tick — and the pass restaged the served value over the landing, so m1 refetched the old input and the landing was lost until the next flight (5 m1 runs, two m1:done(0)). Two readings were spiked on evidence: with the join kept and only the restaging fixed, the button still waited until m1 landed; with the re-pass joining nothing, the button and the signal hole repainted at once, the deferred/m1 holes held as stale readers, and m1 ran 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 reading m1()) joins by ruling 3 with no deferred node involved — pinned beside a pure-L2 control.

Public API changes

  • New export createDeferred from @solidjs/signals and solid-js (createDeferred(compute, options?) with the createMemo overloads, incl. loadingValue). Server: observationally createMemo (D7). Client: hydration-aware through the memo adapter.
  • Behaviour change reachable without createDeferred: commitPendingNode closes a loading-window node's _loading only 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.)
  • Internal only (no public surface): GlobalQueue._deferredLanded and GlobalQueue._slotDerive optional hook slots; affects.ts exports unmark/onSeam for the module; CONFIG_DEFERRED = 1 << 19; recompute keeps REACTIVE_REASK readable through the pass. createDeferred is pay-for-use: nothing of deferred.ts or affects.ts is 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.

  1. src/core/async.ts : asyncWrite — the GlobalQueue._deferredLanded?.(el)
    call after the REACTIVE_DIRTY drop, ahead of landStatus. Is dropping
    the 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?
  2. src/deferred.ts : openFlight + src/core/scheduler.ts : settle /
    commitPendingNode — the wrapper calls queuePendingNode(el) with
    nothing staged, and commitPendingNode now closes _loading only under
    _pendingValue !== NOT_PENDING. Is a pending node with no staged value
    a 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?
  3. src/affects.ts : mark / unmark / onSeam → _releaseAmbientMarks,
    read by src/core/verdict.ts : verdictValue's GlobalQueue._marked
    arm. Is reusing the affects() mark count as createDeferred's verdict
    (scope = the flight; no scope lists, no boundary channel) sound,
    including the sweep running inside affects' release hook?
  4. src/store/store.ts : GlobalQueue._slotDerive + src/deferred.ts :
    unansweredFlight (consumed by until and the refresh waiter in
    src/signals.ts). Is hopping a slot node to its family derive the
    correct and only hole in D9's authoritative dep walk, and is it
    acceptable that until throws NotReadyError for an equal-value
    derived answer?
  5. src/core/scheduler.ts : commitPendingNode — the one behaviour change
    reachable without calling createDeferred: a loading-window node
    committed with nothing staged keeps its window open (before, the sweep
    closed it unconditionally). Can a plain async memo reach
    commitPendingNode with _loading set and nothing staged, and if so is
    "window stays open" what A27 wants there?
  6. src/core/core.ts : recompute, the T4 arm (if (tx._lane || el._config & CONFIG_DEFERRED) {} else if (…) joinPassTx(tx) else if (…) publish mainline) and src/deferred.ts : deferredCompute's async && el._loading arm (result = el._pendingValue over a held landing) +
    seam()'s window re-open. The ruling (D11, 2026-10-04): a held
    deferred 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-passed
    by a mainline write") has the evidence and the L2-general limit;
    pins: tests/createDeferred.test.ts (the common part; "… commits
    mainline; 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 : recompute keeps REACTIVE_REASK
through the pass (the wrapper classifies quiet re-asks by it);
src/core/owner.ts is comment-only after the dispose-clause cut.

core/ footprint (everything else is the module, the store hop, the
public surface, tests and docs):

  • src/core/async.ts — the _deferredLanded hook call (4 lines);
  • src/core/constants.ts — CONFIG_DEFERRED = 1 << 19;
  • src/core/core.ts — the REACTIVE_REASK mask in recompute's pre-pass
    wipe (1 code line); the CONFIG_DEFERRED test in the T4 arm (D11);
  • src/core/owner.ts — comment only;
  • src/core/scheduler.ts — two declare static hook slots
    (_slotDerive, _deferredLanded); commitPendingNode's _loading close
    gated on a staged value.

Tests

  • signals: 4957 passed | 3 expected fail | 2 skipped (next: 4919 | 3 | 2; this PR adds 36 in tests/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).
  • solid-js: 819 passed.
  • @solidjs/web: dom 1140 passed | 1 expected fail (incl. test/create-deferred-mizulu-3710.spec.tsx, the user's exact graph), server 1412 passed | 2 skipped (incl. the deferred-settled-element parity scenario), hydrate 278 passed (incl. test/hydration/create-deferred-lag.spec.tsx).
  • pnpm types clean in signals and solid; rules-index --check green.
  • One RE-PIN, called out for veto: the −5 B size cut dropped the || _loading dispose clause from disposeChildren. 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:

Scenario br now Δ br Δ min cap over
signals: core floor 7336 +15 +24 7330 6
signals: + createStore 14537 +29 +54 14530 7
signals: + isPending/latest 9461 +15 +24 9450 11
app: render + one signal 9830 +18 +24 9830 —
app: hydrating (no stores) 17646 −4 +24 17660 —
app: hydrating + every store family 28747 −38 +54 28800 —
app: CSR 12859 +52 +24 12820 39
app: CSR observe 14399 +10 +24 14390 9
app: CSR observe + attribution 28603 +13 +24 28610 —
frames: eager client 13770 0 0 13780 —
page: base server components 44860 +98 +54 44780 80
page: live server components 48473 +37 +54 48450 23
server: floor 1331 0 0 1340 —
server: renderToString 20412 0 0 20420 —

The structural cost is the uniform +24 B minified in every client scenario (the _deferredLanded hook call 9, the REACTIVE_REASK mask 2, the moved _loading close 1, the CONFIG_DEFERRED test in the T4 arm 12) and +54 B where the store is present (+30 for _slotDerive); deferred.ts itself 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. createDeferred opts one node out of that coordination:

  • Refetches serve the previous answer. Once the node has a committed value, readers keep rendering it during a refetch, the input's write commits immediately, and the landing commits on its own schedule.
  • isPending still shows the refetch, including through sync memos and derived stores built on the node.
  • First load is unchanged. Reads suspend to the nearest <Loading>, and loadingValue composes (the node is born committed).
  • Never leads. A flight asked against a write that another node is holding reveals with that write, not ahead of it.
  • Errors propagate to <Errored>; the stale value never masks them.
  • refresh(d) and until(...) wait for the landed value. until does this transitively, so a predicate that reads the node through a derived memo also waits.
  • On the server it is createMemo.

The design is in packages/signals/docs/create-deferred.md (propositions D1–D9; §6 is the implementation as landed). tests/createDeferred.test.ts pins one test per proposition, plus §2.1, §8.1 and §8.2.

Public API changes

  • New export createDeferred from @solidjs/signals, with the same overloads and options as createMemo.
  • New export createDeferred from solid-js, both the client and server entries. The client version hydrates like createMemo; the server version is createMemo.

No existing export, option or default changed. The only behaviour change reachable without calling createDeferred: commitPendingNode now 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 the optimistic.ts / verdict.ts pattern.

  • The clamp is a compute wrapper, not a core branch. Once the node has answered, each pass re-opens the commit-#0 loading window (_loading = true) before calling the user's compute. Core then serves the committed value, or parks the node, exactly as it does for loadingValue. Every site that makes an answer observable already closes the window.
  • The isPending verdict uses the affects() mark channel. An open flight is a mark that markWalk finds; it is counted in activeAffectsMarks, and downstream companions are re-polled at flight start and at landing.
  • refresh and until (D9) park on an unanswered flight inside their own computes, so read() is untouched. An answer that is staged by a transition counts as answered. That is what lets yield 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 keeps REACTIVE_REASK visible during the pass, so the wrapper can classify a quiet re-ask (A24). The finally mask still clears it.
  • commitPendingNode's window close is gated on a staged value.
  • One hook, GlobalQueue._deferredLanded, called with ?. from commitPendingNode and from asyncWrite'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:

Scenario Δ
Core floor +17
+ createStore +7
+ isPending/latest +25
Simple app +23
Hydrating (no stores) −26
Hydrating + stores +97
CSR app +48
CSR, observe tier +5
Base / live pages +69 / −5

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 (a MockPromise-style value, never a native promise) closes the window at a site with no landing hook. The mark reads closed immediately, but isPending companions 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.ts has 20 tests.
  • packages/solid: 809 passed. packages/web: 1,078 passed.
  • tsc -p tsconfig.build.json is clean for signals and solid.
  • RULES-INDEX.md has been regenerated and is current.
  • Size measured with scripts/size against a fresh build of origin/next.

@changeset-bot

changeset-bot Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 7c4c4f1

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

This PR includes changesets to release 13 packages
Name Type
@solidjs/signals Patch
solid-js Patch
test-integration Patch
@solidjs/web Patch
@solidjs/diagnostics Patch
@solidjs/element Patch
@solidjs/h Patch
@solidjs/html Patch
@solidjs/universal Patch
board-example Patch
todos-server-example Patch
@solidjs/babel-plugin Patch
@solidjs/compiler 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 Sep 29, 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.48 KB +14 B (+0.2%) +24 B +16 B 7.48 KB ⚠️ over by 3 B, 4 B minified headroom
signals: + createStore 14.96 KB +17 B (+0.1%) +53 B +31 B 14.94 KB ❌ over by 20 B
signals: + isPending/latest 9.76 KB +5 B (+0.1%) +24 B +16 B 9.77 KB ✅
app: render + one signal (the simple-app floor) 9.99 KB +32 B (+0.3%) +24 B +16 B 9.98 KB ⚠️ over by 6 B, 4 B minified headroom
app: hydrating (no stores) with Show/For/Loading/Errored/lazy 17.95 KB +33 B (+0.2%) +24 B +20 B 17.93 KB ⚠️ over by 18 B, 0 B minified headroom lazy-page.js 0.04 KB
app: hydrating + every store primitive family 29.59 KB +30 B (+0.1%) +54 B +32 B 29.57 KB ❌ over by 20 B lazy-page.js 0.04 KB
app: CSR with Show/For/Loading/Errored/lazy 13.05 KB +14 B (+0.1%) +24 B +2 B 13.07 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier (same app on the observe artifacts) 14.63 KB −4 B (−0.0%) +24 B +2 B 14.64 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier + attribution engine enabled 29.22 KB −15 B (−0.1%) +24 B +38 B 29.24 KB ✅ lazy-page.js 0.04 KB
app: compiled floor (one template, one text hole, one delegated click) 10.17 KB +9 B (+0.1%) +24 B +16 B 10.17 KB ⚠️ over by 2 B, 4 B minified headroom
app: compiled CSR (JSX todo app: spread/merge/omit, events, class/style, keyed For, Show, Loading + lazy, store) 25.81 KB +6 B (+0.0%) +52 B −47 B 25.79 KB ⚠️ over by 18 B, 67 B minified headroom stats.js 0.18 KB
app: compiled hydrating (the same JSX todo app through hydrate(), compiled hydratable) 31.70 KB +53 B (+0.2%) +54 B +74 B 31.64 KB ❌ over by 57 B stats.js 0.20 KB
frames: eager client consumer (frames client + transport, lazy codec) 11.54 KB 0 B 0 B 0 B 11.54 KB ✅
page: base server components (hydrating + dynamic + frames + sf reference) 34.19 KB −24 B (−0.1%) +24 B −1250 B 34.33 KB ✅ assets.js 0.78 KB, bind.js 1.83 KB, decode.js 6.23 KB, lazy-page.js 0.04 KB, regions.js 0.80 KB, trace.js 9.00 KB, wire.js 0.93 KB
page: live server components (base + live/GET + action + isPending/latest) 38.02 KB +48 B (+0.1%) +24 B −1319 B 38.15 KB ✅ assets.js 0.78 KB, bind.js 1.83 KB, decode.js 6.23 KB, lazy-page.js 0.04 KB, regions.js 0.80 KB, trace.js 8.98 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.10 KB +40 B (+0.1%) +24 B −2340 B 35.66 KB ✅ assets.js 0.78 KB, bind.js 1.83 KB, decode.js 6.23 KB, regions.js 0.80 KB, sc-comments.js 0.20 KB, trace.js 8.95 KB, wire.js 0.93 KB
page: compiled live server components (the compiled base page + live/GET + action + isPending/latest) 40.73 KB −15 B (−0.0%) +23 B −2325 B 41.21 KB ✅ eager (counted): web.js 21.57 KB; assets.js 0.78 KB, bind.js 1.84 KB, decode.js 6.23 KB, regions.js 0.79 KB, sc-comments.js 0.19 KB, trace.js 8.99 KB, wire.js 0.93 KB
page: base + router (base page + @solidjs/router: createRouter, two routes, preload, useNavigate) 41.62 KB −28 B (−0.1%) +24 B −1250 B 41.79 KB ✅ assets.js 0.78 KB, bind.js 1.85 KB, decode.js 6.23 KB, lazy-page.js 0.04 KB, regions.js 0.80 KB, server.js 1.02 KB, serverForms.js 3.58 KB, trace.js 9.00 KB, wire.js 0.94 KB
page: live + router (live page + @solidjs/router: createRouter, two routes, preload, useNavigate) 47.42 KB +105 B (+0.2%) +28 B −1236 B 47.49 KB ✅ eager (counted): client.js 27.78 KB; assets.js 0.78 KB, bind.js 1.84 KB, decode.js 6.23 KB, lazy-page.js 0.04 KB, regions.js 0.81 KB, server.js 1.02 KB, serverForms.js 3.31 KB, trace.js 9.01 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.57 KB 0 B 0 B 0 B 20.58 KB ✅

⚠️ Over the brotli cap within the minified allowance (passes)

  • signals: core floor (createSignal/Memo/Effect/Root/flush): over brotli cap by 3 B; minified 20,452 B vs 20,436 B recorded with the cap (+16 B) — 4 B of the 20 B minified allowance left; +24 B minified over this PR's base
  • app: render + one signal (the simple-app floor): over brotli cap by 6 B; minified 27,961 B vs 27,945 B recorded with the cap (+16 B) — 4 B of the 20 B minified allowance left; +24 B minified over this PR's base
  • app: hydrating (no stores) with Show/For/Loading/Errored/lazy: over brotli cap by 18 B; minified 53,018 B vs 52,998 B recorded with the cap (+20 B) — 0 B of the 20 B minified allowance left; +24 B minified over this PR's base
  • app: compiled floor (one template, one text hole, one delegated click): over brotli cap by 2 B; minified 28,491 B vs 28,475 B recorded with the cap (+16 B) — 4 B of the 20 B minified allowance left; +24 B minified over this PR's base
  • app: compiled CSR (JSX todo app: spread/merge/omit, events, class/style, keyed For, Show, Loading + lazy, store): over brotli cap by 18 B; minified 80,676 B vs 80,723 B recorded with the cap (−47 B) — 67 B of the 20 B minified allowance left; +52 B minified over this PR's base

❌ Fails the gate

  • signals: + createStore: over brotli cap by 20 B; minified 45,396 B vs 45,365 B recorded with the cap (+31 B) — 11 B past the 20 B minified allowance; +53 B minified over this PR's base. real growth: reduce it, or raise the cap and its recorded minified in this PR with a reason (frozen floors need Size-Exception:)
  • app: hydrating + every store primitive family: over brotli cap by 20 B; minified 93,412 B vs 93,380 B recorded with the cap (+32 B) — 12 B past the 20 B minified allowance; +54 B minified over this PR's base. real growth: reduce it, or raise the cap and its recorded minified in this PR with a reason (frozen floors need Size-Exception:)
  • app: compiled hydrating (the same JSX todo app through hydrate(), compiled hydratable): over brotli cap by 57 B; minified 101,035 B vs 100,961 B recorded with the cap (+74 B) — 54 B past the 20 B minified allowance; +54 B minified over this PR's base. real growth: reduce it, or raise the cap and its recorded minified in this PR with a reason (frozen floors need Size-Exception:)

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).

@codspeed

codspeed Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

Merging this PR will improve performance by 6.77%

⚠️ 2 benchmarks spent significant time in system calls

System calls cannot be consistently instrumented, so they are not included in the measure, which understates the real cost. Please switch to the Walltime instrument to accurately measure system calls.

Measurement and system calls

⚡ 1 improved benchmark
✅ 194 untouched benchmarks

Performance Changes

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)

Open in CodSpeed

ryansolid and others added 7 commits October 5, 2026 00:54
… 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).
@ryansolid
ryansolid force-pushed the feat/create-deferred branch from 06307a4 to 721c547 Compare October 5, 2026 07:56
@coveralls

coveralls commented Oct 5, 2026 •

Copy link
Copy Markdown

Coverage Report for CI Build 38605635948

Coverage decreased (-0.09%) to 77.426%

Details

  • Coverage decreased (-0.09%) from the base build.
  • Patch coverage: 1 uncovered change across 1 file (1 of 2 lines covered, 50.0%).
  • No coverage regressions found.

Uncovered Changes

File Changed Covered %
packages/solid/src/client/hydration.ts 2 1 50.0%

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 1328
Covered Lines: 1085
Line Coverage: 81.7%
Relevant Branches: 1042
Covered Branches: 750
Branch Coverage: 71.98%
Branches in Coverage %: Yes
Coverage Strength: 27.4 hits per line

💛 - 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
ryansolid pushed a commit that referenced this pull request Oct 14, 2026
… (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
ryansolid pushed a commit that referenced this pull request Oct 14, 2026
… (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

Copy link
Copy Markdown
Member Author

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

@ryansolid ryansolid closed this Oct 14, 2026
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.

3 participants