Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-lazy-companion-and-store-node-hold.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/fix-promote-unflushed-hot-path.md
Original file line number Diff line number Diff line change
@@ -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).
5 changes: 5 additions & 0 deletions .changeset/latest-held-till-flush.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/optimistic-writes-visible-at-flush.md
Original file line number Diff line number Diff line change
@@ -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.
48 changes: 35 additions & 13 deletions documentation/solid-2.0/MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
<div ref={(el) => {
el.addEventListener("pointerdown", onDown);
onCleanup(() => el.removeEventListener("pointerdown", onDown));
}} />
<div
ref={el => {
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.
Expand All @@ -610,7 +632,7 @@ onSettled(() => {
return () => el.removeEventListener("pointerdown", onDown);
});

<div ref={el} />
<div ref={el} />;
```

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:
Expand All @@ -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);
};
}

<button ref={tooltip({ content: "Save" })} />
<button ref={tooltip({ content: "Save" })} />;
```

Note the timing difference: `onSettled` runs post-settle with the element already in hand, so element work can sit directly inside it; a factory's setup half runs during render before any element exists, so element-dependent work rides the apply callback or an effect.
Expand Down
1 change: 1 addition & 0 deletions packages/signals/docs/INTERNALS-ASYNC-STATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ Every path that produces a value for a node must maintain the companions via
1. `setSignal` — direct write (line ~1033).
2. `asyncWrite` — async resolution, four branches: setter / override-active / lane-routed / plain `setSignal` fallback.
3. `recompute` — transition-held sync derivation (line ~334, `activeTransition || el._transition` guard).
4. Companion creation — `getLatestValueComputed` / `getPendingSignal` backfill a companion created after the owner's state was already produced (`backfillCompanion`). The backfill is the write of the transaction holding that state (`runAsTransitionBatch(el._transition, …)`), not of the ambient window the creating read runs in: written ambiently, the override registered in the reader's batch and reverted at that flush's round end while the hold was still on (#3336). A hold with no transaction stays ambient.

Comparator (`_equals`) errors on any of these paths are node errors, routed
through `notifyStatus(STATUS_ERROR)` `[ruled — #2837]`. `setSignal` checks
Expand Down
23 changes: 23 additions & 0 deletions packages/signals/docs/INTERNALS-STORE-STATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,29 @@ there is nothing to diff.)
record until flush commit — carries no subscriptions, discarded at fold.)
- **(high)** A node is created by: first tracked read, first has/keys tracking,
any transition/optimistic write, projection write to an observed property.
- **(high)** A data node created while the target's pending backing is held
by a live transaction (the #3089 `foldBatches` stamp; plain and projection
families, outside the draft) is born as if it had always existed: committed
value, the backing's value staged as that transaction's write
(`stageHeldKey` — transition-stamped, no subscriber walk, not `UNFLUSHED`:
the write already flushed with its setter). The first tracked read serves
through the node like every later read, so whether a key was materialized
before the hold is unobservable (#3336). Optimistic families hold at the
backing instead (`heldTruthMasked`) and are excluded.
- **(high)** The backing-level visibility decision carries core read()'s
committed clause (`(stale && el._transition !== null && activeTransition !== el._transition) → _value`):
while a live FOREIGN transaction holds the pending backing
(`liveFoldTransition`, `foreignHold`), a stale (render) reader and an
owner-less reader see committed through every channel — untracked reads,
`in`, `Object.keys`, `deep()`/`snapshot()`, the adoption hold view
(`heldFromReader`; `readSource`, `pendingBackingVisible`, `nodeValue`). A
stale reader the holding transaction itself recomputes (its run is the
transaction's to apply) sees the staged world, as it sees `_pendingValue`
in core — otherwise it composes its view, and its deep() subscriptions,
from the pre-hold backing and never re-derives at the silent commit.
Non-stale owner-context readers keep speculation, as in core; a pending
backing with no transaction (same-tick plain write) keeps the snapshot
peek (#3336).
- **(high)** After every flush with no active lanes: for every materialized
node, node's committed view === raw value (single-home coherence).
- **(medium)** Disposal of a store tears down only materialized nodes
Expand Down
Loading
Loading