diff --git a/.changeset/action-await-yield-docs.md b/.changeset/action-await-yield-docs.md new file mode 100644 index 000000000..60a5e88dd --- /dev/null +++ b/.changeset/action-await-yield-docs.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +Docs: inside an `action`, a bare `yield` is required after an `await` before anything that creates a reader — `until()`, `latest()`, a memo or effect, a mount — not only before writes. The `until()` docstring's own example had `await` straight into `yield until(...)`; the `until(...)` expression is evaluated in the post-`await` continuation, outside the transaction, and its predicate reader is born held there (#3482). Example corrected. diff --git a/packages/signals/docs/RULES-INDEX.md b/packages/signals/docs/RULES-INDEX.md index 5251e69b2..09e6e809f 100644 --- a/packages/signals/docs/RULES-INDEX.md +++ b/packages/signals/docs/RULES-INDEX.md @@ -73,7 +73,7 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | A26 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:67` | scheduler.ts×1 | action-await-contract.test.ts×2 visibility-oracle-store.states.ts×1 visibility-oracle.states.ts×1 visibility-oracle.test.ts×1 | [ruled 2026-07-17] An ambient transaction window is one flush; parking is flush-driven — (**ruled 2026-07-17**, #2913; **enforcement hardened 2026-08-31**, #3141 — parking is flush-driven, and a trans… | | A27 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:233` | — | loading-value.test.ts×2 visibility-oracle.states.ts×18 visibility-oracle.test.ts×1 | [ruled 2026-08-10] The commit-#0 loading window is loading-class and verdict-quiet — (**ruled 2026-08-10**) **The commit-#0 loading window is loading-class and verdict-quiet.** A node born committed v… | | A28 | ruled, mechanism landed | `docs/SPEC-ASYNC-SEMANTICS.md:51` | constants.ts×1 core.ts×18 optimistic.ts×1 scheduler.ts×3 types.ts×1 verdict.ts×6 optimistic.ts×3 store.ts×1 | createOptimistic.test.ts×5 latest-held-till-flush.test.ts×1 optimistic-store-layer-scope.test.ts×1 posture-store-parity.test.ts×3 question-scoped-pending.test.ts×3 snapshot-derived-store-rows.test.ts×1 createOptimisticStore.test.ts×10 shallow.test.ts×1 treeshake.test.ts×2 visibility-oracle-store.states.ts×8 visibility-oracle.states.ts×8 | [ruled, mechanism landed 2026-09-15] A write becomes visible at flush — to every channel — (**ruled 2026-09-08**; supersedes the #2922 mid-tick pull) \*\*A write becomes visible at flush — to every chan… | -| A29 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:75` | core.ts×5 effect.ts×1 optimistic.ts×1 | body-end-supersession-visibility.test.ts×1 born-held.test.ts×3 direct-commit-readers-posture.test.ts×1 held-conditional-memo.test.ts×1 held-frame-dependencies.test.ts×2 latest-held-till-flush.test.ts×2 posture-store-parity.test.ts×1 treeshake.test.ts×1 visibility-oracle-store.states.ts×3 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×5 visibility-oracle.test.ts×1 | [ruled, amended in place 2026-09-13 (#3408)] A tracked read served a live transaction's staged value enters that transaction — A tracked computation served a node's staged `_pendingValue` — a value a … | +| A29 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:75` | action.ts×1 core.ts×5 effect.ts×1 optimistic.ts×1 signals.ts×1 | body-end-supersession-visibility.test.ts×1 born-held.test.ts×3 direct-commit-readers-posture.test.ts×1 held-conditional-memo.test.ts×1 held-frame-dependencies.test.ts×2 latest-held-till-flush.test.ts×2 posture-store-parity.test.ts×1 treeshake.test.ts×1 visibility-oracle-store.states.ts×3 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×5 visibility-oracle.test.ts×1 | [ruled, amended in place 2026-09-13 (#3408)] A tracked read served a live transaction's staged value enters that transaction — A tracked computation served a node's staged `_pendingValue` — a value a … | | A30 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:207` | async.ts×1 attribution.ts×1 core.ts×1 effect.ts×1 scheduler.ts×3 | async-landing-deps-3461.test.ts×3 held-conditional-effect.test.ts×1 held-conditional-memo.test.ts×1 held-frame-dependencies.test.ts×2 treeshake.test.ts×1 | [ruled 2026-09-13 (#3410)] A memo's dependencies are the committed frame's until the frame is replaced — A pass that _staged_ its value has not replaced the committed frame, so the committed value sti… | | A31 | live | `docs/SPEC-ASYNC-SEMANTICS.md:83` | core.ts×2 | ispending-combined-atomic-3442.test.ts×1 | [live 2026-09-14 (#3442)] A memo computes under its own lane posture, never its puller's — A memo's value is one shared slot every reader sees, so its pass runs under the lane posture the memo itself … | | A32 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:91` | — | visibility-oracle-store.states.ts×5 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×9 visibility-oracle.test.ts×1 | [ruled 2026-09-14] Children-forbidden readers see the frame, not the graph — `createTrackedEffect` and `onSettled` callbacks are effect-phase code that runs after the frame is decided. They read the f… | diff --git a/packages/signals/src/core/action.ts b/packages/signals/src/core/action.ts index b43fcf668..318c8a5cb 100644 --- a/packages/signals/src/core/action.ts +++ b/packages/signals/src/core/action.ts @@ -64,15 +64,22 @@ function restoreTransition(seq: number, transition: Transition, fn: () => T): * `yield` is the transaction-safe suspension point: the action waits for a * yielded promise and re-enters the transaction before running the code after * it. A plain `await` does NOT — the runtime has no hook into an async - * generator's internal await continuations, so writes to fresh signals - * between an `await` and the next `yield` escape the transaction and commit - * immediately. `await` is still the ergonomic choice for typed results; just - * put a bare `yield` before any writes that follow it: + * generator's internal await continuations, so code between an `await` and + * the next `yield` runs OUTSIDE the transaction: writes to fresh signals + * commit immediately, and anything that creates a reader there — `until()`, + * `latest()`, a memo or effect, a mount — is created mainline, where a read of + * this action's held state makes it born held (A29): staged with the + * transaction and replayed at its commit. For `until()` that commit is the + * settle its own promise holds open (#3482). `await` is still the ergonomic + * choice for typed results; just put a bare `yield` before any write or + * reader creation that follows it — including the expression of the next + * `yield`, which is evaluated before the step re-enters: * * ```ts * const saved = await api.createTodo(text); // typed result - * yield; // re-enter the transaction before writing + * yield; // re-enter the transaction before writing or reading * setTodos(t => { ... }); + * yield until(() => todos.some(t => t.id === saved.id)); * ``` * * (For the same reason, don't call `flush()` inside an action body — it diff --git a/packages/signals/src/signals.ts b/packages/signals/src/signals.ts index 513851e25..8aa09ef00 100644 --- a/packages/signals/src/signals.ts +++ b/packages/signals/src/signals.ts @@ -932,12 +932,20 @@ export interface UntilOptions { * * Must be called *outside* a tracking scope. * + * Inside an action, call it from a step: after an `await`, put a bare `yield` + * before `yield until(...)`. The runtime cannot hook an async generator's + * `await` continuation, so the `until(...)` expression — which CREATES the + * predicate's reader — would otherwise run outside the transaction; created + * there it is born held (A29) and replays only at the commit its own promise + * holds open (#3482). See {@link action}. + * * @example * ```ts * const send = action(async function* (text: string) { * const clientId = crypto.randomUUID(); * setMessages(m => { m.push({ clientId, text, pending: true }); }); // optimistic * await socket.send({ clientId, text }); // fire-and-forget transport + * yield; // re-enter the transaction after the await * // Hold until the live source echoes the write (authoritative view — * // the optimistic row above cannot satisfy this): * yield until(() => messages.some(m => m.clientId === clientId), { timeout: 10_000 });