diff --git a/.changeset/fix-lazy-companion-and-store-node-hold.md b/.changeset/fix-lazy-companion-and-store-node-hold.md new file mode 100644 index 000000000..cf45dd58a --- /dev/null +++ b/.changeset/fix-lazy-companion-and-store-node-hold.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +A lazily created `latest()`/`isPending()` companion answers as if it had always existed (#3336). Its backfill is now the write of the transaction holding the owner's staged value rather than of the ambient window the creating read runs in — previously the override registered in a render effect's batch and reverted at that flush's end, so `latest(a)` fell back to committed and `isPending(a)` flipped false while the action still held the write, and two identical cells disagreed depending on whether some other reader had created a companion before the hold. One level down, a store key first read under a hold is born holding: the node takes the committed value with the held write staged as the holding transaction's, so a plain read of a never-before-observed key no longer leaks the held write while an observed sibling stays committed. Every other store read channel now answers the same way: untracked reads inside a render effect, `in`, `Object.keys`, `deep()` and `snapshot()` see committed while a live transaction holds the backing, exactly as core `read()` serves a stale reader of a foreign transaction's write — previously those channels served the pending backing to any owner-context reader. diff --git a/.changeset/fix-promote-unflushed-hot-path.md b/.changeset/fix-promote-unflushed-hot-path.md new file mode 100644 index 000000000..fd34691d5 --- /dev/null +++ b/.changeset/fix-promote-unflushed-hot-path.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +Recover the write-path cost of A28's deferred promotion: `recompute` no longer pays two calls and an array truncation per run when it wrote nothing (the unflushed list lives in core.ts and is compared locally), `promoteUnflushed` returns before truncating an empty list, and plain nodes skip the override probe. update1to1 was ~25% slower on the #3337 head; the residual is the deferred subscriber walk itself (~5% on a pure-write microbench, parity on dbmon). 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/.changeset/optimistic-writes-visible-at-flush.md b/.changeset/optimistic-writes-visible-at-flush.md new file mode 100644 index 000000000..dbab203f0 --- /dev/null +++ b/.changeset/optimistic-writes-visible-at-flush.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +Optimistic writes become visible at flush (A28 for overrides). `setOptimistic(x)` / an optimistic store setter no longer installs the override synchronously: the write parks on the node and becomes the active override when the flush carries it, so plain reads, `snapshot()` and `isPending()` answer the flushed value until then — the same rule every other write follows, and the same visibility React's `useOptimistic` gives. An ambient optimistic write (no action in flight) is shown by its flush to effects and reverted at the flush's end. The setter's functional updater, a store setter's draft, and the `affects()` declaration walk still compose on the tick's own earlier writes (two `count++` are +2; a toggle toggled back cancels; `affects(parent)` after a push covers the pushed row). To mark a single slot of a row you are adding, call `affects` on the draft row inside the setter — the row is not readable through the store until the flush. diff --git a/documentation/solid-2.0/MIGRATION.md b/documentation/solid-2.0/MIGRATION.md index c9e5d62aa..769d91560 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 @@ -379,6 +390,15 @@ const addTodo = action(function* (todo) { }); ``` +Optimistic writes follow the same rule as every other write: they become visible at the flush that carries them, not synchronously. Reading `optimisticTodos.list` on the line after `setOptimisticTodos(...)` still answers the previous value (the same as React's `useOptimistic`, which shows the optimistic value on the next render). The setter's draft does see the writes earlier setters made in the same tick, so two `s.count++` in a row are +2, and `affects(parent)` covers everything under the parent as the writer sees it — a row you just pushed included. To mark only one slot of a row you are adding, name it on the draft, where it already exists: + +```js +setOptimisticMessages(s => { + s.list.push({ text, status: "sending" }); + affects(s.list[s.list.length - 1], "status"); // not `messages.list[...]` — that row is not readable yet +}); +``` + ## Stores ### Draft-first setters (and `storePath` as an opt-in helper) @@ -592,10 +612,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 +632,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 +643,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); }; } -