Skip to content
Closed
8 changes: 8 additions & 0 deletions .changeset/add-create-deferred.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@solidjs/signals": patch
"solid-js": patch
---

Add `createDeferred`: an async memo that may lag the global clock but never leads it. Once it has a committed value, a refetch is served the previous answer instead of suspending — readers keep rendering, the input's write commits immediately, and the landing commits on its own schedule — while `isPending` stays loud (including through sync derivations and derived stores). The first load still suspends to the nearest `<Loading>`; `loadingValue` composes. A flight asked against a write another node holds reveals with that write (never ahead of it), and one asked by an action's write is the action's. `refresh(d)` and `until` wait for the landed truth, including a predicate that reads the node through a derived memo or a derived store. Errors propagate. On the server it is `createMemo`; on the client it hydrates like one.

Built on the hold model (L2): the clamp is the commit-#0 loading window re-opened per pass, the verdict is an `affects()` mark whose scope is the flight, and "never leads" is the seam's own hold decision over the flush's pending nodes. Core carries two optional hooks (`GlobalQueue._deferredLanded`, `_slotDerive`; the seam sweep rides `affects()`'s release hook), a staged-value gate on `commitPendingNode`'s window close, and `REACTIVE_REASK` kept readable through a pass. The only behaviour change reachable without calling `createDeferred`: a loading-window node committed with nothing staged keeps its window open (before, the commit sweep closed it unconditionally). A held `createDeferred` node re-passed by a mainline write (its landing staged by a downstream async memo's hold) keeps that landing and joins nothing: the write commits mainline and the readers of the held flight catch up at its landing (D11).
2 changes: 2 additions & 0 deletions packages/signals/docs/INTERNALS-ASYNC-STATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ ruling. Anything marked `[ruled]` cites the decision.

**Store** (`store/*`): the target (`target.ts` — `v`, `pb`, `k` the container node, `n` leaves, `h` presence, `dk` deep, `fam`), `readSource` (which backing a read serves — the key is the hold's unit), `pullFamily` (the firewall as a pull), `holdWithDerive`, `stageOn`/`_storePark`/the fold queue (`queueFold`/`drainFolds`/`foldTarget`), `optimistic.ts` (guesses on lanes, `LaneView`, `_laneRebase`), `projection.ts` (`runProjectionComputed`, `wakeFamily` via `propagateStatus`, `settleFamily`, `errorFamily`), `affects.ts` (the store half of marks).

**Deferred** (`src/deferred.ts`, 2026-10-04 — `docs/create-deferred.md` §6): `createDeferred`'s clamp is the commit-#0 loading window (`_loading`) re-opened per answered pass by a compute wrapper (`deferredCompute`, which registers an async-shaped result through `handleAsync` itself and opens a flight only for one still in the air); its verdict is an `affects()` mark whose scope is the flight (`openFlight`/`closeFlight` — `mark`/`unmark` from `affects.ts`, the push `wake`); "never leads" is the wrapper queueing the node as a pending node of the flush it ran in, for the seam's hold decision. Hooks: `GlobalQueue._deferredLanded` (`asyncWrite`, ahead of the landing's write), `_slotDerive` (store: a slot node's family derive, for `until`'s D9 walk); the seam sweep (a rejected flight, a dead node) rides affects.ts's `_releaseAmbientMarks` (`onSeam`). `commitPendingNode` closes the window only for a staged value; `recompute` keeps `REACTIVE_REASK` readable through the pass, and its T4 arm skips a `CONFIG_DEFERRED` node (D11, 2026-10-04: a held deferred node's re-pass — a downstream hold staged its landing, a mainline write re-passed it — joins nothing and keeps the held landing; the wrapper returns the staging as the pass's result, and the seam sweep re-opens a window a commit closed under a newer question). A node disposed mid-flight outside a flush keeps its mark until the next seam that runs (`disposeChildren` schedules none for it).

## 1. Node state fields

A `Signal`/`Computed` participating in async/transitions carries:
Expand Down
44 changes: 22 additions & 22 deletions packages/signals/docs/RULES-INDEX.md

Large diffs are not rendered by default.

863 changes: 600 additions & 263 deletions packages/signals/docs/create-deferred.md

Large diffs are not rendered by default.

20 changes: 19 additions & 1 deletion packages/signals/src/affects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,10 +106,27 @@ function register(node: Marked): void {
}
repoll(node);
}
function mark(node: Marked): void {
/** One mark on the node, outside any scope list: `createDeferred`'s flight
* (deferred.ts) registers one at its start and `unmark`s it at its close —
* a mark whose window is the flight, released by the module that owns the
* flight (the push half is its own). */
export function mark(node: Marked): void {
ext(node)._marks++;
active++;
}
export function unmark(node: Marked): void {
node._x!._marks--;
active--;
}
/** A scope sweep run at every seam, ahead of the ambient release: a module
* whose marks' window is neither a transaction's nor the flush's
* (deferred.ts: the flight's) decides there which of its marks ended. The
* seam hook is this module's (`_releaseAmbientMarks`); a second core hook
* for the same instant would cost the floor. */
const sweeps: Array<() => void> = [];
export function onSeam(sweep: () => void): void {
sweeps.push(sweep);
}

/**
* Declares that in-flight work will change the targeted data: the source —
Expand Down Expand Up @@ -181,6 +198,7 @@ GlobalQueue._marked = el => active !== 0 && marked(el, new Set());
GlobalQueue._mark = mark;
GlobalQueue._releaseMarks = release;
GlobalQueue._releaseAmbientMarks = parked => {
for (let i = 0; i < sweeps.length; i++) sweeps[i]();
if (ambient.length === 0) return;
// The flush parked: the declaration's window is the frame's — the marks
// are its transaction's ("nothing async below ⇒ no window").
Expand Down
4 changes: 4 additions & 0 deletions packages/signals/src/core/async.ts
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,10 @@ export function handleAsync<T>(
// skip this stale async result — the upcoming flush will recompute the node
// with the new value, creating a fresh Promise that supersedes this one.
if (el._flags & REACTIVE_DIRTY) return;
// A `createDeferred` flight landed (deferred.ts): its mark drops ahead of
// the write, as `landStatus` clears a plain memo's pending ahead of it —
// the verdict readers the write re-runs read the landing as final.
GlobalQueue._deferredLanded?.(el);
const wasUninitialized = !!(el._statusFlags & STATUS_UNINITIALIZED);
landStatus(el);
// Attribution hook: lets the engine snapshot state before the landing
Expand Down
5 changes: 5 additions & 0 deletions packages/signals/src/core/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,11 @@ export const CONFIG_REDERIVE = 1 << 15;
* pass no lane's, and the truth landing wakes it even when it confirms the
* guess (the one case ordinary subscribers are not told — A17's silence). */
export const CONFIG_AUTHORITATIVE = 1 << 17;
/** A `createDeferred` node (deferred.ts). Read in one place: `recompute`'s
* T4 arm — a pass over a held deferred node is a lagging question, not an
* answer: it joins no transaction and does not publish the held landing
* mainline (reading 2 of create-deferred.md 8.3). */
export const CONFIG_DEFERRED = 1 << 19;
// Presence bits (stage-3 hot-path monomorphism, DESIGN-PATCH-CHANNEL §11b):
// optional per-node slots (_overrideValue, _pendingSignal/_latestValueComputed,
// _snapshotValue, _optimisticLane) are NOT part of every node's hidden class —
Expand Down
13 changes: 11 additions & 2 deletions packages/signals/src/core/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
$REFRESH,
CONFIG_AUTO_DISPOSE,
CONFIG_CHILDREN_FORBIDDEN,
CONFIG_DEFERRED,
CONFIG_FRESH_READ,
CONFIG_GUESS,
CONFIG_VERDICT,
Expand Down Expand Up @@ -324,7 +325,13 @@ export function recompute(el: Computed<any>, create: boolean = false): void {
// Held by a blocked lane: the frame joins nothing — a lane never holds
// a sync write (#3460). The pass is the lane's if it reads the lane's
// world (`read`), and has left it otherwise.
if (tx._lane) {
// A deferred node held because a downstream hold staged its landing
// (CONFIG_DEFERRED, deferred.ts): its re-pass is a lagging question,
// not an answer — it stages nothing (the wrapper returns the held
// landing as its result) and joins nothing, so a mainline write that
// reaches it commits mainline; the landing stays staged for the
// hold's reveal and the new flight's landing re-enters it (A34 (1)).
if (tx._lane || el._config & CONFIG_DEFERRED) {
} else if (isEffect !== EFFECT_RENDER && !(el._config & CONFIG_VERDICT)) joinPassTx(tx);
else if (
tx !== flushTransaction &&
Expand Down Expand Up @@ -379,7 +386,9 @@ export function recompute(el: Computed<any>, create: boolean = false): void {
// frame, and the splice/reawaken paths key off it. A zombie reruns for
// live writes until the commit that disposes it, so every per-pass wipe
// — here, the finally below, updateIfNecessary — carries it (#3543).
el._flags = REACTIVE_RECOMPUTING_DEPS | (el._flags & REACTIVE_ZOMBIE);
// REACTIVE_REASK stays readable for the pass (`createDeferred`'s wrapper
// classifies its flight by it, deferred.ts); the finally below drops it.
el._flags = REACTIVE_RECOMPUTING_DEPS | (el._flags & (REACTIVE_ZOMBIE | REACTIVE_REASK));
el._time = clock;
// The pass's previous value: its staging, else a lane node's lane value
// when it has one (lanes.ts), else the committed one.
Expand Down
4 changes: 3 additions & 1 deletion packages/signals/src/core/owner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,9 @@ export function disposeChildren(node: Owner, self: boolean = false, zombie?: boo
// A death that can unblock the future (L2, `futureBlocked`): a held
// flight's — a transaction's or a lane's (its `_x._transaction`) — or a
// stale reader's (REACTIVE_FRAME_READ — a frame that derived from one).
// Nothing else re-examines a dead node, so make sure a seam comes.
// Nothing else re-examines a dead node, so make sure a seam comes. (A
// `createDeferred` flight dying open is swept at the next seam that
// runs, deferred.ts; a death outside a flush does not schedule one.)
if (
((node as Computed<unknown>)._statusFlags & STATUS_PENDING &&
(node as Computed<unknown>)._x?._transaction != null) ||
Expand Down
19 changes: 15 additions & 4 deletions packages/signals/src/core/scheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -636,6 +636,15 @@ export class GlobalQueue implements IQueue {
declare static _applyGuesses: ((parent: Transaction | null) => void) | undefined;
/** A slot node's truth is in flight (its family's derive — store/store.ts). */
declare static _slotFlight: ((n: Signal<any>) => boolean) | undefined;
/** A slot node's family derive (store/store.ts): the node a read through
* the family pulls without linking — `createDeferred`'s authoritative
* walk hops it (deferred.ts). */
declare static _slotDerive: ((n: Signal<any>) => Computed<any> | null) | undefined;
/** `createDeferred` (deferred.ts): the node's flight landed (`asyncWrite`,
* ahead of the landing's write, as `landStatus` is): its mark releases,
* the readers parked on it wake. (Its seam sweep rides affects.ts's
* `_releaseAmbientMarks` — `onSeam`.) */
declare static _deferredLanded: ((el: Computed<any>) => void) | undefined;
/** The value a slot node's guess covered: its committed value — a chained
* link's refreshed from the inner store, whose commits it never learned
* while the guess served the reads (store/store.ts, §7b). */
Expand Down Expand Up @@ -1172,6 +1181,12 @@ export function commitPendingNode(n: Signal<any>): void {
// the reveal. (The sweep of a node still in flight is not a landing.)
// The A28 stash goes with it.
if (c._x != null) ((c._x._reask = false), (c._x._flushed = NOT_PENDING));
// The committed value is the first observable answer for a loading-window
// node — the window closes here, not at compute time (#2990). Gated like
// the re-ask clear: a window with nothing staged is still in flight — a
// `createDeferred` flight queued for the seam's hold decision (D2) and
// committed by a sweep, or held and landed without it (deferred.ts).
c._loading = false;
// An effect with a staged value was born held (L2): its first run is
// this commit's, not its creation's (A29) — queue it now.
if ((n as any)._type && (n as any)._type !== EFFECT_TRACKED) {
Expand All @@ -1182,10 +1197,6 @@ export function commitPendingNode(n: Signal<any>): void {
);
}
}
// The committed value is the first observable answer for a loading-window
// node — the window closes here, not at compute time (#2990). Unconditional
// store to an always-present computed slot.
c._loading = false;
c._flags! &= ~REACTIVE_MANUAL_WRITE;
// The dependencies of the pass that produced the value are the frame's now:
// the previous frame's tail goes (A30, #3410; `recompute` left it for a
Expand Down
Loading
Loading