Skip to content
Merged
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/write-proposals-3494.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/signals": patch
---

A write is a proposal (A34, #3494). A mainline write to a node a transaction holds — the same value again or another — is a second proposal for the same slot: the writer's tick joins the hold at the next flush's start and reveals with it (`setA(1); setB(1)` with `b=1` held holds A with B again; #3473 had dropped the entry, and with it the grouping, while fixing its `activeTransition` leak — the entry is now deferred instead). A tick whose writes net to the committed value proposed nothing: the node is not staged, not stamped, and not pending (`setShow(false); setShow(true)` beside a held `setCount(1)` no longer reads `isPending(show)` true nor captures the later `setShow(false)` into the hold — the hide that used to be lost). Fixes the torn `[1, 0, 1]` effect input from #3473's review. Also: a stale reader served a held memo's committed value counts as observing the memo's flight (a reveal in the flush that retires the flight's last reader holds the transaction — `Count: 1` no longer publishes beside a visible `Copy: 0`), and a `latest()` shadow backfilled under a transaction never blocks its settle (removing the last reader releases the held write at once). From differential fuzzing of the ruling and review: a source going pending behind a memo's kept dependency tail now re-derives the memo instead of marking it pending (A30 amendment) — a reader that stopped reading a memo is no longer registered on the memo's next flight (which held an action's truth on a fetch nobody displayed), and a memo whose committed frame still derives from the source no longer publishes stale beside the source's new inputs (`1 0` with `selected` derived from `remote(0)`); the no-proposal drop applies to unstamped nodes only — a held proposal rewritten to the committed value stays its transaction's, so the authoritative value that follows commits instead of being skipped as another transaction's — and covers writable memos (`createSignal(fn)`) as it covers signals; a same-value write to a held node schedules its own flush, so the join never leaks into a later, unrelated tick; and the join drain runs inside the flush's guard, so a throwing comparator cannot wedge the scheduler.
3 changes: 3 additions & 0 deletions packages/signals/docs/INTERNALS-ASYNC-STATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,9 @@ Semantics of the `(_pendingValue, _overrideValue)` pair for an optimistic node
- `transitionComplete`: prunes dead reporters (`reporterBlocksSource`), transition is done when no live reporter still blocks a source that is **still pending** (a non-empty `_pendingSources`) and no active-override node is blocked on someone else's async. Judged by the set, not `_error.source` (#3375: a later-pending input overwrites it on propagation while the flight is still in the air) and not the self entry alone (#3462: an upstream re-ask that supersedes the source's own flight retires that entry and leaves the source pending on the re-ask — its reader still cannot render, and the landing folds the transaction in; judged complete instead, a re-entry before the landing, such as a repeated write to the held signal, committed the held writes beside the reader's stale frame). **Zombies (#3463):** a reporter with `REACTIVE_ZOMBIE` — its owner's pass replaced it, its disposal staged in a live transaction (held children, #3404) — is still on screen and is live for every hold but one: `reporterBlocksSource(reporter, source, verdict)` walks the zombie's `_parent` chain to the first non-zombie owner and resolves the transaction staging its removal (the owner's `_transition`, or `activeTransition` for a `CONFIG_HELD_CHILDREN` owner); the zombie is moot only when that transaction is the one being judged (`verdict`) — done, and the commit disposes it; not done, and it stays parked regardless — or is already done. `sourceObserved(transition, source, verdict?)` passes `verdict ? transition : null`, and keeps (rather than prunes) a zombie the verdict passed over: moot for this verdict, it still holds a lane's reveal while the transaction stays parked on something else. Before, a zombie counted as disposed everywhere, and the lane revealed `Value: 1` beside the zombie's `Details: 0`. Pinned: `tests/lane-outside-view.test.ts` (#3463).
- Fallback-caught async holds nothing — in both orders (A33; ruled 2026-09-12, #3375). A collecting boundary consumes the notification, so a reader under a fallback never registers. A reader registered while its boundary showed content (forwarded) stays registered when the boundary's `on` changes and it flips to the fallback; `reporterBlocksSource` therefore walks the reporter's `_queue._parent` chain and treats a reporter behind a collecting pending-type boundary (`_collectionType & STATUS_PENDING && !_initialized`) as not live. If nothing outside the boundary consumes the flight, the hold is over; a reader outside it still holds. The reset itself calls `wakeParked()` so the re-judgement happens in the same drain. The hold moves onto the boundary, not off the screen (#3459): the reset also collects, from every live transaction's `_asyncReporters` (INV-3, the one record of a forwarded reader), the sources of each reporter it routes — `_holds`: under this queue with no collecting pending-type boundary between — plus that reporter's `_pendingSources`, and flips to the fallback if it found any. A forwarded reader already pending never re-notifies (status propagation dedupes on its `_pendingSources`), so without this a sibling reader's fresh flight was the only source collected, and its landing revealed the still-flying one stale (`B: 1 | Fast: 1 | Slow: 0`). Pinned: `tests/loading-reset-collects-forwarded-3459.test.ts`.
- Wake of parked transactions (`wokenTransitions`): the flush judges only the _active_ transaction; a parked one is re-entered by a stamped node's landing (`settleTransition`), a stamped recompute, or an action resuming. A reporter that stops counting for another reason — its boundary reset (above), or its disposal by ambient work (#3372: `disposeChildren(self)` on a node with `_transition` and `STATUS_PENDING`; a pending reader is always queued as a pending node, so the stamp is reliable) — is none of those: `reporterBlocksSource` would prune it at the next check, but no check comes, and the writes held with it stay staged. Such sites record the transaction (deduped) and schedule; the flush re-enters a woken transaction from the `finally` of a full pass — reached from the park exit and the normal exit alike — and only when idle: no `activeTransition` and `!scheduled`, which at that point means an empty dirty heap, no write since the heap ran (every write re-arms it) and, the finalize having reverted them, no optimistic ambient nodes. Entering adopts the ambient batch, and ambient work present at that instant would be held behind flights it never read; a wake in a pass with work just falls to the next. Entries are popped in a loop until one enters: a wake whose transaction completed by other means is a bare return (`initTransition` on `_done`) and must not strand the ones behind it. The fast drain defers to the full path while a wake is outstanding so such dead entries are still consumed. A wake with other live reporters re-parks; the idle pass is its only cost. Known shape: the ambient write that triggered the reset commits in its own pass and the released hold in the idle pass after it — two effect runs in one synchronous drain (`Sum: 1`, `Sum: 2` at the same clock time in the #3375 pin), never a visible tear.
- A write proposes (A34, #3494). Two mechanisms. **Join at flush (`batchJoins`, scheduler.ts):** `setSignal` on a node stamped by a transaction that is not active enters it at once inside a flush; from mainline it pushes the transaction to `batchJoins` (dupes are harmless: `initTransition` on the active transaction returns) and calls `schedule()` — the write may be a repeat that leaves through the equality gate without scheduling, and a join left for a later flush adopted that flush's unrelated tick (#3519 review); `flush()` enters each at its start — after the companion re-sync, inside the `try` (the adoption runs user comparators; a throw must not leave `_running` set), before the heap — adopting the tick's ambient batch; the fast sync path (`canUseSimpleSyncFlush`) is bypassed while a join waits, so the whole tick reveals with the hold ("both are suggesting a value"). Recorded before `setSignal`'s equality gate: a repeat of the held value is a proposal too, and had no other route in (a differing value re-asks the flight, which the adoption already held). #3473 had removed the entry from mainline because it set `activeTransition` for the rest of the caller's block — a memo created after the write was born the transaction's (A29) — and thereby dropped the grouping; deferring the entry keeps mainline code between the write and the flush mainline. **No proposal (adoption loop of `initTransition`):** an unstamped (`_transition === null`) signal (`!_fn`), or an initialized writable memo under `REACTIVE_MANUAL_WRITE` (`createSignal(fn)`'s setter routes through `setMemo → setSignal`; parity, #3519 review — a computed's other stagings are its pass's result and may equal an uninitialized `undefined`), whose staged value equals its committed one (`_equals`) is unstaged through `commitPendingNode` with `_pendingValue` cleared first (the commit's own cleanup: companions snapped, the manual-write flag dropped) and neither stamped nor pushed to the transaction — its subscribers were walked at the write and re-derive the same value; its companions need no snap, the flush-start re-sync (A28) already read the equality through `computePendingState`. `computePendingState` gates its "staged, therefore pending" arm on the same inequality (a staged value equal to the committed one is final). Before, `setShow(false); setShow(true); setCount(1)` stamped `show` into `count`'s hold with nothing to reveal: `isPending(show)` read true for the hold's life, and the later `setShow(false)` — the tick above routes it to the stamp — was held and then lost (gabbev's coalesced-toggle case). Pinned: `tests/write-proposals-3494.test.ts`. The unstamped guard (fuzzer latest-2 #1470, S3): a parked transaction's batch folds into a merge through this same loop, and its nodes carry FLUSHED proposals — an action's `1` then `0` on a committed `0` is a held node, not a fresh tick's no-op. Dropped, the node kept its dead stamp and was pushed nowhere; the authoritative `1` that followed queued under the merged transaction as another transaction's node, and `commitPendingNodes` skipped it — readers revealed `1` beside a source anchor stuck at `0`.
- A pending mark over a kept-tail link re-derives its subscriber (A30 amendment, #3494 review; fuzzer latest-1 #2141, O2; #3519 review; fuzzer branches-1 #1105): `notifyStatus`'s `forEachDependent` callback, for `STATUS_PENDING` when `link._gen !== sub._depGen` — the link was not (re)validated by the subscriber's current pass (`link()` stamps the pass generation on every link in the `[deps.._depsTail]` prefix), i.e. it lies in the tail A30 keeps (a staged or unchanged pass) or, mid-pass, has not been re-read yet — calls `enqueueSub(sub); schedule()` and returns: no `_pendingSources` entry, no `initTransition(sub._transition)` (the A15 held-memo entanglement arm), no downstream `notifyStatus`. O(1). Clears and errors (`STATUS_NONE`/`STATUS_ERROR`) still ride every link. The recompute decides: reading the pending dep registers the node through its own read; reading a held input enters that transaction (A29); reading neither leaves nothing. Why not skip (the first amendment): a mainline flight on a dep the committed frame derives from left that frame on screen beside its new inputs — `selected` (held pass: constant `0`, tail to `remote` kept) published `1 0` with `query=1`. Why not mark (base): the mark's registration and entanglement bind the node's holder to a flight the held frame never reads — a hide joined to a parked action (A34) held the action's truth on the memo's orphaned re-ask; a branch memo held in a transaction, its kept-tail dep a manual flight, entangled the transaction with a flight nobody resolved and kept a gated reader hidden (#1105, S2/L1). Gating `reporterBlocksSource` instead was wrong (first attempt, S1 flood in the fuzzer): it runs mid-pass from `heldFromStale` → `waitingTransition`, when the reporter's tail sits at the dep just read and the pending dep it is about to throw on is still past it — the registration was pruned and never re-added.
- Reporter liveness through a memo (#3494): `reporterBlocksSource`'s deps scan, for each dependency of this pass, also asks `dep._x._pendingSources.has(source)` — a stale reader served a held memo's committed value never turned pending itself, so its only trace of the flight is the memo between them; `_pendingSources` is transitive, one hop covers any depth. Beside a release in the same flush (a gate closes on the flight's last reader as a new reader reveals the memo), the verdict judged the new reader dead and released `count=1` beside the `Copy: 0` it displayed. And `transitionBlocked` (optimistic.ts) skips companions (`_parentSource`): a `latest()` shadow created from mainline over a pending memo is backfilled under the owner's transaction (A28 (3)) with an active override and a `NotReadyError` — the exact shape of an authoritative blocker — and held a released write until the orphaned request landed.
- Staged reads enter (A29, #3408): `read()` calls `enterStagedRead` on every selection that returns `_pendingValue` — the fast paths and the slow path — and it enters `el._transition` unless that is null (ambient batch), already active, or the read is a probe (`pendingCheckActive`). The third entry beside `setSignal` on a stamped node and `recompute` of a stamped node; rule text and the `Panel: 1` beside `Count: 0` shape live in the spec. A stale (render) reader never reaches it: the carve-out below serves it the committed value.
- Dependencies are the committed frame's (A30, #3410): `recompute`'s tail trims the previous pass's dependency tail only for a pass that published or changed nothing (`_pendingValue === NOT_PENDING` and no `_error`); a staged pass leaves it linked and `commitPendingNode` trims after a clean pass (`_error == null` — a set `_error` means the last pass threw, kept its full list, and `_depsTail` marks where it stopped); an effect pass that direct-committed but still owes a run (`_modified`, #3438 — the flush may stash that run into a transaction it opens later) leaves it for `runEffect` to trim once the run applies. `__OBSERVE__` fan-in counting walks the validated prefix only, so a held tail does not inflate distinct-source counts. Why commit-time and not the pass, and the `Selected: 0` beside `Count: 1` shape: the spec. **Unchanged pass (#3469, `heldTrims`, scheduler.ts):** a pass that changed nothing replaced nothing either, and cannot know at its own tail whether the flush that ran it will park with its inputs held — `b() ? b() : a()` computed `1` from the held `b=1`, equal to the `1` it had from `a`, trimmed `a`, and the mainline `a=2` never reached it. `recompute`'s tail now trims at once only for a creation pass, an OPT-dirty pass, or a tracked effect (its frame is replaceable like a direct commit — it runs after the commit and a spurious run is user-visible); any other unchanged pass with a stale tail is pushed to `heldTrims`, drained by `commitPendingNodes` (the flush committed: trim) and cleared when the flush parks (the tail stays linked until a committing pass trims it — one spurious recompute at most). Pinned: `tests/held-frame-dependencies.test.ts`.
- Reveal-hold and its carve-out (#3305, #3334, re-ruled 2026-09-10): a reader landing on a node with `STATUS_PENDING` throws — the throw reaches `GlobalQueue.notify`, which opens a transaction for the reveal if none is active (#3305) and records the source as its reporter (INV-3); the reveal completes when the flight lands. One carve-out, the staged-value rule's twin for flights: a **stale** (render) reader of a node pending in some **other** transaction shows the node's committed value, does not entangle (its own writes stay outside that transaction), is recorded for that transaction's commit replay (`heldFromStale`), and joins the transaction's reporters for the node when it has an entry (#3374) — the reader displays the pre-flight value, so the transaction cannot commit the flight's inputs ahead of its answer just because the reader that opened the entry was disposed (a keyed remount). It is refused — the reader holds — when the committed value would tear against the frame: the node carries `CONFIG_INPUTS_PUBLISHED` (a batch or transaction committed with the node still pending, `commitPendingNode`'s computed branch: the flight's inputs are on screen; cleared when the node next enters pending from a settled state, `notifyStatus`), or the node is routed through a live lane (`GlobalQueue._laneLive` → `resolveLane`, exact rather than sticky: lane-revealed inputs, optimistic or `latest`), or the node is uninitialized (nothing committed to show). The stamp itself is pending-node bookkeeping and decides nothing. Replay hygiene: an effect recorded in `_gatedSubs` that later recomputes _under_ the transaction sees its staged view and is applied by the commit (ownership) — `recompute` drops the stale recording at its start (`activeTransition._gatedSubs.delete`), and a lane's committed-view read re-records during the run, so the lane replay (`laneReadsCommitted`) is untouched.
Expand Down
Loading
Loading