Skip to content

feat: createDeferred, an async memo that may lag but never leads (D-40) - #4067

Merged
ryansolid merged 8 commits into
nextfrom
claude/create-deferred-d40
Oct 15, 2026
Merged

ryansolid merged 8 commits into
nextfrom
claude/create-deferred-d40

Conversation

@ryansolid

@ryansolid ryansolid commented Oct 14, 2026 •

Copy link
Copy Markdown
Member

Requested by Ryan · project thread

Summary

Supersedes #3710: it carries that PR's pins (signals D1–D11 incl. mizulu's graph, the web and hydration specs) on a smaller core footprint.

Before: under the 2.0 hold model, every refetch of an async memo holds whatever reads it. An independent widget (a dashboard panel, a feed, search results) can't keep showing its last answer with a "refreshing" indicator while the rest of the screen moves on. This was the migration gap raised in #3910.

After: createDeferred(fn, options?) is an async memo that may lag but never leads. Before its first answer it acts like any async memo, so readers suspend to <Loading>. After that, a refetch holds nothing:

  • Readers keep the committed value, and isPending reports the flight on the node, on its derivations and on derived store leaves.
  • refresh(d) is a quiet re-ask. await refresh(d) and until wait for the landing, not the stale value.
  • A refetch that read a held input lands with that hold, so it never shows a value ahead of a held change.
  • Once a downstream hold has taken its landing, a write that re-asks it still holds nothing: the write commits now and the hold keeps the landing (D11, mizulu's report on feat(signals,solid): createDeferred — an async memo that may lag, but never leads #3710).
  • A rejection errors like any memo.
  • It works with loadingValue for a node that never suspends.

Rules: F-1 and F-2 gain a declared-lag exception (amended 2026-10-14). D-40 is the new derived section, S-41 is the new scenario table, and RULES-INDEX is regenerated. The design and as-built notes are in packages/signals/docs/create-deferred.md.

How: it is built from parts the engine already has.

  • The loading window serves the committed value during a flight.
  • CONFIG_REDERIVE turns an upstream pending into a re-derive, not a pushed status.
  • One affects mark per flight carries the verdict. The mark core moves out of affects.ts into affects-marks.ts so both can use it, and the mark walk now reaches derived-store leaves through a family hook on the projection derive.
  • holdNode lands a refetch into a held input's transaction.
  • An answered 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 (D11).
  • A generic GlobalQueue._lagging check in watch makes truth readers (refresh's waiter, until) wait out a flight.

core.ts keeps REACTIVE_REASK visible through the recompute pass so a re-ask stays quiet. On the server, createDeferred is createMemo. Hydration goes through the signal-like adapter.

Size (minified vs next @ 0109a56): every client scenario grows +14/+15 B, and the store scenarios +17 B. Of that, 12 B is the D11 check in recompute's held-node arm and 2 B keeps REACTIVE_REASK through the pass. The rest is property-mangler reshuffle. #3710 measured +24 / +54. Importing createDeferred costs about 1.6 KB minified.

Size-Exception: createDeferred (D-40) — the D11 check in recompute (a held deferred re-pass joins nothing, 12 B) and the REACTIVE_REASK mask (2 B) add +14 B minified on the floors. next already sat 18–19 B over their recorded minified, so the floors are re-recorded at this head's measurement: core floor 7.48 → 7.50 KB (20,436 → 20,469), render + one signal 9.98 → 10.01 KB (27,945 → 27,977; measured 9,995 B, still under 10 KB), plus scenarios.js for + isPending/latest 9.77 → 9.79 KB (27,447 → 27,480) and compiled floor 10.17 → 10.21 KB (28,475 → 28,507). Each cap is set at measured + 10 B, rounded up to 0.01 KB. Accepted by the maintainer 2026-10-14.

After merging next @ eb4e019 (2026-10-15): #4073's seedless derived stores and #4075's still-streaming branch each sat inside the 20 B minified allowance alone, and together with this PR they pass it. Re-recorded, each at measured + 10 B: + createStore 14.94 → 15.00 KB (45,365 → 45,395), hydrating + every store family 29.69 → 29.76 KB (93,753 → 93,770), compiled hydrating recorded minified 101,369 → 101,387 (cap unchanged), and one more frozen floor, hydrating (no stores) 18.09 → 18.11 KB (53,350 → 53,386), covered by the Size-Exception above. MIGRATION.md now says createDeferred returns with new semantics instead of being removed.

How did you test this change?

  • packages/signals: npx vitest run gives 5282 passed, and treeshake pins cover the deferred module and the mark core. tests/create-deferred-3710.test.ts carries feat(signals,solid): createDeferred — an async memo that may lag, but never leads #3710's 36 pins, with the disposal pin rewritten to this PR's behaviour (the mark drops at disposal). The new tests/create-deferred.test.ts has 15 tests: panels, first load, isPending through derivations and store leaves, error boundary, quiet refresh, landing into a hold, unrelated hold, wrapping form, Loading fallback only on first load, until, streams, disposal, and loadingValue.
  • packages/solid: 862 passed, including the new create-deferred-hydration.spec.ts (serialized first answer, then a lagging refetch) and a server test.
  • packages/web: feat(signals,solid): createDeferred — an async memo that may lag, but never leads #3710's create-deferred-mizulu-3710.spec.tsx, hydration/create-deferred-lag.spec.tsx and the deferred-settled-element harness scenario are ported. Those and the compiled-size scenarios need the compiler binary, which isn't available locally, so CI covers those.

🤖 Generated with Claude Code

https://claude.ai/code/session_01S2HrYgWeGJX6hKw7bD5LQN


Generated by Claude Code


Generated by Claude Code

@ryansolid ryansolid self-assigned this Oct 14, 2026
@changeset-bot

changeset-bot Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: c037364

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

This PR includes changesets to release 14 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
server-dbmon-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 Oct 14, 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 +6 B (+0.1%) +14 B 0 B 7.50 KB ✅
signals: + createStore 14.99 KB +26 B (+0.2%) +17 B 0 B 15.00 KB ✅
signals: + isPending/latest 9.77 KB 0 B +15 B 0 B 9.79 KB ✅
app: render + one signal (the simple-app floor) 9.99 KB +13 B (+0.1%) +14 B 0 B 10.01 KB ✅
app: hydrating (no stores) with Show/For/Loading/Errored/lazy 18.09 KB +5 B (+0.0%) +15 B 0 B 18.11 KB ✅ lazy-page.js 0.04 KB
app: hydrating + every store primitive family 29.75 KB +69 B (+0.2%) +17 B 0 B 29.76 KB ✅ lazy-page.js 0.04 KB
app: CSR with Show/For/Loading/Errored/lazy 13.20 KB +5 B (+0.0%) +15 B −54 B 13.20 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier (same app on the observe artifacts) 14.80 KB +46 B (+0.3%) +15 B −52 B 14.79 KB ⚠️ over by 12 B, 72 B minified headroom lazy-page.js 0.04 KB
app: CSR, observe tier + attribution engine enabled 29.37 KB −25 B (−0.1%) +15 B −51 B 29.38 KB ✅ lazy-page.js 0.04 KB
app: compiled floor (one template, one text hole, one delegated click) 10.19 KB +1 B (+0.0%) +14 B 0 B 10.21 KB ✅
app: compiled CSR (JSX todo app: spread/merge/omit, events, class/style, keyed For, Show, Loading + lazy, store) 25.95 KB −5 B (−0.0%) +18 B −42 B 25.92 KB ⚠️ over by 25 B, 62 B minified headroom stats.js 0.18 KB
app: compiled hydrating (the same JSX todo app through hydrate(), compiled hydratable) 31.82 KB −50 B (−0.2%) +18 B 0 B 31.89 KB ✅ stats.js 0.20 KB
frames: eager client consumer (frames client + transport, lazy codec) 11.84 KB 0 B 0 B 0 B 11.84 KB ✅
page: base server components (hydrating + dynamic + frames + sf reference) 34.70 KB +10 B (+0.0%) +14 B +11 B 34.33 KB ⚠️ over by 361 B, 9 B minified headroom assets.js 0.78 KB, bind.js 2.54 KB, decode.js 6.23 KB, lazy-page.js 0.04 KB, regions.js 0.80 KB, trace.js 9.04 KB, wire.js 1.35 KB
page: live server components (base + live/GET + action + isPending/latest) 38.47 KB +7 B (+0.0%) +15 B −52 B 38.15 KB ⚠️ over by 327 B, 72 B minified headroom assets.js 0.78 KB, bind.js 2.54 KB, decode.js 6.23 KB, lazy-page.js 0.04 KB, regions.js 0.80 KB, trace.js 9.03 KB, wire.js 1.35 KB
page: compiled base server components (the base page as JSX: templates with class/style/attributes/events, For/Show; no spread) 35.53 KB +22 B (+0.1%) +14 B −1079 B 35.66 KB ✅ assets.js 0.78 KB, bind.js 2.54 KB, decode.js 6.23 KB, regions.js 0.80 KB, sc-comments.js 0.20 KB, trace.js 8.97 KB, wire.js 1.35 KB
page: compiled live server components (the compiled base page + live/GET + action + isPending/latest) 41.21 KB +50 B (+0.1%) +15 B −1066 B 41.21 KB ✅ eager (counted): web.js 21.73 KB; assets.js 0.78 KB, bind.js 2.55 KB, decode.js 6.23 KB, regions.js 0.79 KB, sc-comments.js 0.19 KB, trace.js 8.99 KB, wire.js 1.35 KB
page: base + router (base page + @solidjs/router: createRouter, two routes, preload, useNavigate) 42.05 KB +2 B (+0.0%) +14 B +5 B 41.79 KB ⚠️ over by 259 B, 15 B minified headroom assets.js 0.78 KB, bind.js 2.56 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.03 KB, wire.js 1.36 KB
page: live + router (live page + @solidjs/router: createRouter, two routes, preload, useNavigate) 47.84 KB +48 B (+0.1%) +15 B +10 B 47.49 KB ⚠️ over by 345 B, 10 B minified headroom eager (counted): client.js 27.91 KB; assets.js 0.78 KB, bind.js 2.56 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.30 KB, trace.js 9.00 KB, wire.js 1.36 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 +8 B 20.58 KB ✅

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

  • app: CSR, observe tier (same app on the observe artifacts): over brotli cap by 12 B; minified 41,891 B vs 41,943 B recorded with the cap (−52 B) — 72 B of the 20 B minified allowance left; +15 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 25 B; minified 80,999 B vs 81,041 B recorded with the cap (−42 B) — 62 B of the 20 B minified allowance left; +18 B minified over this PR's base
  • page: base server components (hydrating + dynamic + frames + sf reference): over brotli cap by 361 B; minified 106,715 B vs 106,704 B recorded with the cap (+11 B) — 9 B of the 20 B minified allowance left; +14 B minified over this PR's base
  • page: live server components (base + live/GET + action + isPending/latest): over brotli cap by 327 B; minified 118,938 B vs 118,990 B recorded with the cap (−52 B) — 72 B of the 20 B minified allowance left; +15 B minified over this PR's base
  • page: base + router (base page + @solidjs/router: createRouter, two routes, preload, useNavigate): over brotli cap by 259 B; minified 130,596 B vs 130,591 B recorded with the cap (+5 B) — 15 B of the 20 B minified allowance left; +14 B minified over this PR's base
  • page: live + router (live page + @solidjs/router: createRouter, two routes, preload, useNavigate): over brotli cap by 345 B; minified 144,134 B vs 144,124 B recorded with the cap (+10 B) — 10 B of the 20 B minified allowance left; +15 B minified over this PR's base

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

Copy link
Copy Markdown
Member Author

The Size check is red, but the cause is already on next (its own Size run at 47d2346 fails the same way). Three hydrating scenarios measure 61 B minified over their recorded size on next, which is 41 B past the allowance: "hydrating (no stores)", "hydrating + every store family" and "compiled hydrating". This PR adds +3 / +17 / +18 B minified on top of that. No fix for the base drift is open yet, so I have not touched the caps here.


Generated by Claude Code

@coveralls

coveralls commented Oct 14, 2026 •

Copy link
Copy Markdown

Coverage Report for CI Build 38664875775

Coverage decreased (-0.005%) to 77.548%

Details

  • Coverage decreased (-0.005%) from the base build.
  • Patch coverage: 2 of 2 lines across 1 file are fully covered (100%).
  • No coverage regressions found.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 1328
Covered Lines: 1086
Line Coverage: 81.78%
Relevant Branches: 1046
Covered Branches: 755
Branch Coverage: 72.18%
Branches in Coverage %: Yes
Coverage Strength: 27.55 hits per line

💛 - Coveralls

@codspeed

codspeed Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

Merging this PR will improve performance by 11.48%

⚠️ 1 benchmark 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

⚠️ 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

⚡ 3 improved benchmarks
✅ 192 untouched benchmarks

Performance Changes

Benchmark BASE HEAD Efficiency
⚡ memo + sync render effect only (reference) 30.6 ms 26.7 ms +14.63%
⚡ projection derive: write one NESTED field (reference) 2.4 ms 2.1 ms +13.08%
⚡ build + consume 741.9 µs 694.1 µs +6.89%

Tip

Curious why performance improved? Comment @codspeedbot explain why performance improved on this PR, or directly use the CodSpeed MCP with your agent.


Comparing claude/create-deferred-d40 (c037364) with next (eb4e019)

Open in CodSpeed

ryansolid commented Oct 14, 2026 •

Copy link
Copy Markdown
Member Author

CodSpeed flags two benchmarks: mount 4000 rows › memo + sync render effect only (reference) at −7.9% and dbmon shallow full tick at −5.0%. Both also showed up after the rebase (ee141b9). I don't think they're this PR:

  • Heads 492a84b and a237699 ran the same core code (everything apart from the store walk and an artifact JSON), and both benchmarks were untouched there.
  • 2bf1ac4 changed nothing but a harness JSON file, and it is the first head where the mount-rows regression appears.
  • On the paths these benchmarks run, this PR's only core change is the REACTIVE_REASK bit in a mask that is already constant-folded. The new D11 check sits inside the CONFIG_HELD arm, which neither benchmark reaches.
  • CodSpeed reports "different runtime environments" for these comparisons.

I'm reading them as runner noise and haven't changed anything for them.


Generated by Claude Code

Amends F-1/F-2 with a declared-lag exception: once a createDeferred node
has answered, its refetches hold nothing. Readers keep the committed value,
isPending reports the flight (on the node, its derivations and derived
store leaves), refresh() is a quiet re-ask, and until/await refresh wait
for the landing. A refetch that read a held input lands with that hold.

Built on the loading window, CONFIG_REDERIVE and a mark per flight; the
mark core moves out of affects into affects-marks.ts so createDeferred can
reuse it. Server createDeferred is createMemo; hydration goes through the
signal-like adapter.

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
…n the mark core

The projection derive now carries `fam` (the object) instead of a closure,
and the repoll iterates the family's live nodes itself. That moves the
iteration off the store floor into affects-marks.ts, which only
createDeferred and affects() load: -12 B minified on every store scenario.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S2HrYgWeGJX6hKw7bD5LQN
Server createDeferred is createMemo, so the recording is
async-settled-element's under its own name.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S2HrYgWeGJX6hKw7bD5LQN
@ryansolid
ryansolid force-pushed the claude/create-deferred-d40 branch from 2bf1ac4 to ee141b9 Compare October 14, 2026 20:41

Copy link
Copy Markdown
Member Author

After the rebase onto next (0109a56), Size fails on four floors: core floor, + isPending/latest, render + one signal, and compiled floor. This failure is from this PR. next already sits 18–19 B over the recorded minified size on these floors, and this PR adds +14 B to each:

  • 12 B is the D11 check in recompute's held-node arm.
  • 2 B is the REACTIVE_REASK mask.

Even without D11 the floors would land 1 B past the 20 B allowance. Getting this green needs a Size-Exception: re-record of the frozen floors, and that is the maintainer's decision. It's waiting on that decision now.


Generated by Claude Code

Size-Exception accepted by the maintainer 2026-10-14: the D11 check in
recompute's held-node arm (12 B) and the REACTIVE_REASK mask (2 B), on
floors that next already left 18-19 B over their recorded minified. Core
floor 7.48 -> 7.50 KB, render + one signal 9.98 -> 10.01 KB (9,995 B,
under 10 KB), + isPending/latest 9.77 -> 9.79 KB, compiled floor
10.17 -> 10.21 KB; recorded minified at this head.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S2HrYgWeGJX6hKw7bD5LQN
…d-d40

# Conflicts:
#	packages/signals/docs/RULES-INDEX.md
…ateDeferred)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018LY7F9czoRqdMEvk2QwPr1
…d-d40

# Conflicts:
#	scripts/size/scenarios.js
@ryansolid
ryansolid merged commit b8617ec into next Oct 15, 2026
8 checks passed
ryansolid pushed a commit that referenced this pull request Oct 15, 2026
createDeferred (#4067) grew the signals core: + until/refresh goes over
its cap (8.07 -> 8.11 KB, 22,195 -> 22,255 B minified), and the four SC
page scenarios sit within 1-6 B of the minified allowance. Re-record them
at the merged tree's measurement: frozen page floors 106,772 -> 106,786
and 118,990 -> 119,009, router pages 130,653 -> 130,667 and
144,190 -> 144,205. Brotli caps on the pages are unchanged.

Accepted by the maintainer (2026-10-15, "Ratchet up").

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LZkrATgvFJjgFRdDB4Drcq
ryansolid added a commit that referenced this pull request Oct 15, 2026
… follows (#4084)

* fix(frames): morph drops a stale text node instead of relocating what follows

An empty text hole renders `<!--$--><!--/-->` with no text node. When a
hole went from text to empty, reconcileChildren met the old text node
against the new closing comment, inserted the comment, then relocated
the next element (found by lookahead) in front of the stale text. Every
emptied cell re-inserted its following subtree.

A text node holds no client state, so when the new side has no text at
that position the reconcile now drops it and lines up again.

server-dbmon, 400 rows, 10% changing, 4x CPU: 28 popover subtrees
re-inserted per tick -> 0; main-thread busy 771-910 -> 571-705 ms/s.
Size: +71 B minified on the frames client and the server-component pages.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LZkrATgvFJjgFRdDB4Drcq

* chore(size): note the maintainer's acceptance of the base page floor raise

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LZkrATgvFJjgFRdDB4Drcq

* chore(size): ratchet up caps to what next measures after #4067

createDeferred (#4067) grew the signals core: + until/refresh goes over
its cap (8.07 -> 8.11 KB, 22,195 -> 22,255 B minified), and the four SC
page scenarios sit within 1-6 B of the minified allowance. Re-record them
at the merged tree's measurement: frozen page floors 106,772 -> 106,786
and 118,990 -> 119,009, router pages 130,653 -> 130,667 and
144,190 -> 144,205. Brotli caps on the pages are unchanged.

Accepted by the maintainer (2026-10-15, "Ratchet up").

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LZkrATgvFJjgFRdDB4Drcq

---------

Co-authored-by: Claude <noreply@anthropic.com>
ryansolid pushed a commit that referenced this pull request Oct 15, 2026
Conflicts: D-41/S-42/S-43 (errors) and next's D-40/S-41 (createDeferred,
#4067) both sit after D-39/S-40; kept both, D-41 in section E and the new
section F after it. Size caps re-raised from next's caps against next @
4a1ed17 (same Size-Exception). CONFIG_STAGED_ERROR moves to 1 << 21:
createDeferred's CONFIG_LAGS took 1 << 19, and a lagging node read as a
staged error.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EBzTHZM41ipGacdiFva1ju
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