From 9ccaa8c925a225a2f3696a294abb285446291400 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Wed, 9 Sep 2026 17:57:20 -0700 Subject: [PATCH 01/10] =?UTF-8?q?fix(signals):=20writes=20become=20visible?= =?UTF-8?q?=20at=20flush=20=E2=80=94=20latest()=20reads=20the=20flushed=20?= =?UTF-8?q?staged=20world=20(A28)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A write is unflushed between set() and the next flush(): not committed, not the staged value latest()/isPending() serve, and not an input to any recompute. latest(count) after setCount(30) answers the pre-write value until the flush that carries the write; after it, latest(count) and latest(doubled) agree in the same instant. One rule for every reader — handler, memo, prop getter — so a latest read wrapped in a memo answers the same as the bare read, and the visibility mismatch that motivated a separate readStaged does not arise. Mechanics: every write takes one path — stage, mark CONFIG_UNFLUSHED (stash the last-flushed staged value in _flushedStaged when rewriting a held node), schedule. Consumers promote: flush() at the start of each round and before every clock++, recompute at its tail for writes it issued. Promotion clears the mark, restores the flushed view, syncs companions, walks subscribers. Removed: the eager/deferred write heuristic, the #2922 mid-tick shadow pull in latestRead, and the _notifiedAt / notifyEpoch duplicate-walk dedupe. Core floor 22,247 B (budget 22,350 B; was 21,931 B) — a conscious bump for a write path with no special cases. Spec: A28 in SPEC-ASYNC-SEMANTICS.md. Migration note under latest(fn). Co-authored-by: Claude via Cursor Co-authored-by: Cursor --- .changeset/latest-held-till-flush.md | 5 + documentation/solid-2.0/MIGRATION.md | 39 ++-- packages/signals/docs/SPEC-ASYNC-SEMANTICS.md | 53 ++--- packages/signals/src/core/async.ts | 40 ++-- packages/signals/src/core/constants.ts | 14 ++ packages/signals/src/core/core.ts | 71 +++--- packages/signals/src/core/graph.ts | 4 +- packages/signals/src/core/scheduler.ts | 118 +++++++++- packages/signals/src/core/types.ts | 14 +- packages/signals/src/core/verdict.ts | 80 +++---- .../signals/tests/createTrackedEffect.test.ts | 17 +- .../tests/isPending-memo-consistency.test.ts | 13 +- .../tests/latest-held-till-flush.test.ts | 215 ++++++++++++++++++ .../tests/latest-plain-write-purity.test.ts | 28 ++- .../latest-probe-order-independence.test.ts | 11 +- .../tests/latest-repeated-writes.test.ts | 63 +++-- .../tests/latest-unobserved-memo.test.ts | 12 +- .../projection-transition-isolation.test.ts | 8 +- packages/signals/tests/treeshake.test.ts | 22 +- 19 files changed, 623 insertions(+), 204 deletions(-) create mode 100644 .changeset/latest-held-till-flush.md create mode 100644 packages/signals/tests/latest-held-till-flush.test.ts diff --git a/.changeset/latest-held-till-flush.md b/.changeset/latest-held-till-flush.md new file mode 100644 index 000000000..e57b3f532 --- /dev/null +++ b/.changeset/latest-held-till-flush.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +Writes become visible at flush — to every channel. `latest()` and `isPending()` now read the flushed staged world: `setCount(30); latest(count)` answers the pre-write value until the flush that carries the write, after which `latest(count)` and `latest(doubled)` agree in the same instant. This gives `latest` one rule regardless of reader (handler, memo, prop getter) and removes the mid-tick shadow pull introduced for #2922. All writes now take a single path (stage, mark unflushed, schedule) and are promoted by `flush()`/`recompute` — the eager write heuristic, the `_notifiedAt`/notify-epoch dedupe, and the `latestRead` unflushed branch are gone. Spec: A28. diff --git a/documentation/solid-2.0/MIGRATION.md b/documentation/solid-2.0/MIGRATION.md index c9e5d62aa..21373e02c 100644 --- a/documentation/solid-2.0/MIGRATION.md +++ b/documentation/solid-2.0/MIGRATION.md @@ -319,12 +319,12 @@ const user = createMemo(() => fetchUser(id())); The resource tuple features map to standalone APIs: -| 1.x resource feature | 2.0 replacement | -| -------------------- | --------------------------------------------------------------------------- | +| 1.x resource feature | 2.0 replacement | +| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `resource.loading` | `Loading` (initial), `isPending(() => resource())` (in-flight input change); a bare `refresh()` is silent — use `affects(resource); refresh(resource)` or a co-written optimistic flag for refetch affordances | -| `resource.error` | `Errored` boundary or effect `error` option | -| `refetch()` | `refresh(resource)` | -| `mutate()` | `createOptimisticStore` + `action` (see [RFC 06](06-actions-optimistic.md)) | +| `resource.error` | `Errored` boundary or effect `error` option | +| `refetch()` | `refresh(resource)` | +| `mutate()` | `createOptimisticStore` + `action` (see [RFC 06](06-actions-optimistic.md)) | See [RFC 05 — createResource migration](05-async-data.md#createresource--async-computations--loading) for detailed before/after examples of each pattern. @@ -348,6 +348,17 @@ const listPending = () => isPending(() => users() || posts()); const latestId = () => latest(id); ``` +`latest` shows the newest value a **flush** has processed, even while a transition is still holding it back from the screen. It is not a way to read a write before the flush that carries it — a write becomes visible to every channel at flush, and `latest` is no exception: + +```js +setCount(30); +latest(count); // still the previous value — nothing downstream has run yet +flush(); +latest(count); // 30, and latest(doubled) is 60 in the same instant +``` + +Updates are batched, so inside an event handler this is rarely what you want anyway: prefer to write, and let the graph derive. If you do need to read back imperatively after a write, `flush()` first. + ### “Refetch/refresh” patterns → `refresh()` ```js @@ -592,10 +603,12 @@ In Solid 1.x, a ref callback ran inside the reactive owner of the component that ```jsx // 1.x — the ref callback ran owned, so cleanup could live inside it -
{ - el.addEventListener("pointerdown", onDown); - onCleanup(() => el.removeEventListener("pointerdown", onDown)); -}} /> +
{ + el.addEventListener("pointerdown", onDown); + onCleanup(() => el.removeEventListener("pointerdown", onDown)); + }} +/> ``` In Solid 2.0, ref callbacks are **unowned** — `getOwner()` returns `null` inside them. This makes plain refs consistent with the apply phase of directive factories (below): the callback's only job is to capture or touch the element. Lifecycle work belongs in an owned scope, in one of two packagings. @@ -610,7 +623,7 @@ onSettled(() => { return () => el.removeEventListener("pointerdown", onDown); }); -
+
; ``` For reusable behavior, use a directive factory: its setup half runs owned at component creation — primitives and `onCleanup` live there — and the returned apply callback (the actual ref) is unowned and only captures the element: @@ -621,17 +634,17 @@ function tooltip(options) { const instance = createTooltipInstance(); createEffect( () => options.content, - (content) => el && instance.setContent(content) + content => el && instance.setContent(content) ); onCleanup(() => instance.destroy()); - return (nextEl) => { + return nextEl => { el = nextEl; instance.attach(nextEl); }; } -