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
10 changes: 10 additions & 0 deletions .changeset/flush-record-changes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@solidjs/signals": patch
"solid-js": patch
---

The `flush` record lists what each drain changed on screen; the attribution hooks shrink around it

- **`FlushEvent.changes`**: one entry per signal, memo or store property whose shown value the drain changed — by a commit, a held transition's landing, or an optimistic value appearing or lifting — with `nodeId`, `nodeName`, `key` (store properties) and `origin` (the action step, effect, interaction or navigation that staged the value; a held write keeps its origin, so a landing names its action). First values and effects are not listed. New types `FlushChange` and `FlushLive`: the record's `live` is now `{ changes: [{ node, prev?, value? }] }`, index for index with `event.changes`, the values only for a listener that subscribed with `{ bodies: true }`.
- **Visibility is judged once, by the engine.** Core reports where a value was staged (`AttributionHooks.staged`, from `queuePendingNode` and `list`), never where it became visible; the engine compares what each staged node showed before and after the drain. `OPTIMISTIC_REVERTED` and the `fallback` record's display instant are judged from that comparison. Removed: the `optimisticReverted` hook, the engine's per-transaction copy of staged fallbacks (`stagedFallbacks` and its rebasing on settle/merge), and the `schedule()` a mounting boundary made so the engine got a drain end.
- **Hook inputs reduced to what core knows** (internal `AttributionHooks`): `recomputeEnd(el, create, changed, phase, held)` replaces the `optimistic`/`transition` pair with the `phase` the engine derived from them, and absorbs `derivedChanged`; `asyncLanded(el, value, changed)` replaces `asyncStart` + `asyncEnd(direct)` — an async landing through `setSignal` is reported once, not first as a `write` (the engine no longer snapshots the node to tell whether it committed); `storeWrite(target, old, written, keys)` replaces `storeReplaced`, and the immutable-update census moves from the store into the engine; `boundaryFallback` takes the boundary's output instead of the transaction its swap is staged under.
6 changes: 3 additions & 3 deletions documentation/solid-2.0/08-dev-diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -666,7 +666,7 @@ When a hold commits, `stackedHolds.count` or more interactions (default 3) were

**Message:** "the optimistic value of `status` showed "saved"; it reverted to "idle". The person saw the guess, then the correction. A revert on failure is the feature; one that recurs says the guess is wrong for this input or the action fails often — show the failure where the value renders (the action's catch, an `Errored` boundary) rather than letting the value snap back on its own."

An optimistic override (`createOptimistic`) the screen displayed was replaced by a different value. Two roads, named in `data.how`: `reverted` — nothing new landed and the override lifted back to the committed value it covered (the action failed, or never wrote what it promised); `superseded` — an authoritative value landed that differs from the guess (the server counted differently), and tracked readers re-derived to it. Both are correct by construction — the override reverting _is_ the feature — so the finding is `info`, structured channel only, always on while the engine is enabled: a count that grows for one source is what says the guess or the failure rate is wrong. Judged by the node's own `equals`, so a structurally equal correction is silent. The runtime's own optimistic nodes are never judged: an `isPending()`/`latest()` companion is an optimistic signal that goes `true` while pending and back to `false` at commit by design — the acknowledgement `SILENT_HOLD` asks for — and a derived override promotes rather than reverts; the check skips them by the same predicate the hold census uses. `optimisticReverts: false` disables. `data`: `source`, `shown`, `truth` (previews, 40 characters), `how`. Subject: the node, so `ownerPath` locates it. Not yet covered: optimistic _stores_ (`createOptimisticStore`), whose overlay folds off per path; and the interaction that wrote the guess (optimistic writes are not stamped with an origin today).
An optimistic override (`createOptimistic`) the screen displayed was replaced by a different value. Two roads, named in `data.how`: `reverted` — nothing new landed and the override lifted back to the committed value it covered (the action failed, or never wrote what it promised); `superseded` — an authoritative value landed that differs from the guess (the server counted differently), and tracked readers re-derived to it. Both are correct by construction — the override reverting _is_ the feature — so the finding is `info`, structured channel only, always on while the engine is enabled: a count that grows for one source is what says the guess or the failure rate is wrong. Judged at the end of the drain that shows the correction, by the node's own `equals`, so a structurally equal correction is silent. The runtime's own optimistic nodes are never judged: an `isPending()`/`latest()` companion is an optimistic signal that goes `true` while pending and back to `false` at commit by design — the acknowledgement `SILENT_HOLD` asks for — and a derived override promotes rather than reverts; the check skips them by the same predicate the hold census uses. `optimisticReverts: false` disables. `data`: `source`, `shown`, `truth` (previews, 40 characters), `how`. Subject: the node, so `ownerPath` locates it. Not yet covered: optimistic _stores_ (`createOptimisticStore`), whose overlay folds off per path; and the interaction that wrote the guess (optimistic writes are not stamped with an origin today).

### Server rendering (`ssr`, `head`)

Expand Down Expand Up @@ -880,7 +880,7 @@ Each `DiagnosticEvent` has:
| `nodeName` | `string?` | Debug name of the signal/node involved |
| `data` | `object?` | Additional context |

An event is a serializable record and never carries the node it is about. The live node arrives **beside** it instead, as the listener's second argument — `OBSERVE.diagnostics.subscribe((event, subject) => …)` for findings, `OBSERVE.records.subscribe(type, (event, live) => …)` for records — for a consumer that runs in-process: the console reporter uses it to print the DOM element a binding effect writes; devtools use it to go from a record to the scope. `subject` is `undefined` for a finding with no location (an interaction has no node) or a host finding whose owners are not signals' owners; `live` is what the record type declares (the computation for a re-run, the held signal for a hold, nothing for a flush). A copy of the event that left the process and came back has no subject: the handle was never on it.
An event is a serializable record and never carries the node it is about. The live node arrives **beside** it instead, as the listener's second argument — `OBSERVE.diagnostics.subscribe((event, subject) => …)` for findings, `OBSERVE.records.subscribe(type, (event, live) => …)` for records — for a consumer that runs in-process: the console reporter uses it to print the DOM element a binding effect writes; devtools use it to go from a record to the scope. `subject` is `undefined` for a finding with no location (an interaction has no node) or a host finding whose owners are not signals' owners; `live` is what the record type declares (the computation for a re-run, the held signal for a hold, the changed nodes for a flush). A copy of the event that left the process and came back has no subject: the handle was never on it.

### `OBSERVE.records` — the runtimes' records channel

Expand Down Expand Up @@ -1236,7 +1236,7 @@ createRoot(() => {

**Records and clocks.** Everything the engine hands out — `RerunEvent`, `InteractionEvent`, `HoldEvent`, `NavigationEvent` — is a record with an absolute `at` on the `performance.now()` clock (`RerunEvent.at` the run's start, `HoldEvent.at` the start of the wait, `NavigationEvent.at`/`InteractionEvent.at` the request/dispatch) plus durations from it (`holdMs`, `settledMs`, `selfMs`). Epoch time for an exporter is `performance.timeOrigin + at` (milliseconds). Without cross-origin isolation the browser quantizes `performance.now()` to 100µs, so a single run's `selfMs` is often `0`; the per-interaction `settledMs` is the wall-clock number to report. Records are serializable as emitted: none carries a live graph reference — a re-run names its scope by `nodeId` (the engine's per-node id, stable across the scope's runs in the process, distinct between scopes; in-process consumers get the node itself as the listener's second argument, `live`) — while the frame objects that join records (`origin`, `interaction`) are the same object across records in-process, so join by identity there and by `ChangeOrigin.run`/`at`/`name` once they have left it. `OBSERVE.records.subscribe(type, listener)` delivers each record synchronously at the moment it is complete (a re-run at recompute end; an interaction, hold or navigation when it settles), bottom-up: a hold before the navigation it held, before the interaction that performed it — the same objects `history(type)` keeps. A listener runs inside the engine and must not write signals; hand work off to a microtask. A `RerunEvent` is built — dep diff, previews, cause list, ring-buffer push — only while something wants it: a `rerun` listener, an imported fold (`costs`/`feedback`) or the console log. Without one the engine still keeps its per-node facts and runs every check (hot runs, hot time, wasted recompute, dep width) from them, allocates no record, and `history("rerun")` stays empty.

**Timeline records.** Five further record types describe the work itself rather than its outcome, shaped for a profiler track (`@solidjs/web/performance-tracks`, below) and for the responsiveness findings; they enter no ring buffer and are built only while something is subscribed to them (`OBSERVE.records.observed(type)`), so an app that does not listen pays nothing beyond the hook null-check. `flush` — one per scheduler drain: `at`, `durationMs`, `runs` and `created` inside it, `held` (a transition parked), `interaction`. `create` — a computation's creation run (`RerunEvent`'s shape without causes: `nodeId`, `nodeName`, `nodeKind`, `at`, `selfMs`, `totalMs`, `depCount`, `phase`, `held`, `interaction`) — the mount work that no `RerunEvent` describes, and what `InteractionEvent.created` counts. `effect` — an effect's callback, the imperative half that writes the DOM: `nodeId`, `nodeName`, `at`, `durationMs`, `run`, `interaction`. `flight` — an async node's flight, kickoff to landing: `nodeId`, `nodeName`, `ownerPath`, `at`, `durationMs`, `outcome: "landed" | "abandoned"` (superseded before it landed), `interaction`. `fallback` — a `<Loading>` boundary's fallback, displayed to hidden: `ownerPath`, `at`, `shownMs`, `interaction`. `at` is the end of the drain that rendered the swap — the boundary's swap is a staged write that lands with its frame ([RFC 05](05-async-data.md#loading-on-prop-dependencies-that-show-the-fallback-again)), and a swap cleared before that (the content landed before the frame did, or the commit's own sweep cleared it ahead of any effect — the `LOADING_ON_OUTSIDE_HOLD` shape) was never on screen and is no record. Every derived `ChangeRecord` on a re-run's cause chain also carries the `nodeId` of the memo it came from, so a consumer can walk the propagation of one write node by node.
**Timeline records.** Five further record types describe the work itself rather than its outcome, shaped for a profiler track (`@solidjs/web/performance-tracks`, below) and for the responsiveness findings; they enter no ring buffer and are built only while something is subscribed to them (`OBSERVE.records.observed(type)`), so an app that does not listen pays nothing beyond the hook null-check. `flush` — one per scheduler drain: `at`, `durationMs`, `runs` and `created` inside it, `held` (a transition parked), `interaction`, and `changes` — what the drain changed on screen: one entry per signal, memo or store property whose shown value differs from what it showed before the drain (`nodeId`, `nodeName`, `key` for a store property, and `origin`, the action step, effect, interaction or navigation that staged the value), whether it got there by a commit, a held transition's landing, or an optimistic value appearing or lifting. A held write is listed by the drain that lands it and keeps the origin it was staged under, so a landing names its action; a node's first value and effects are not listed. The comparison is by identity (`Object.is`) of the shown value, not the node's `equals`: a signal with `equals: false` re-set to the same object re-runs its readers but is no change here, and a store object mutated in place is listed through the property nodes whose values moved. The record stays serializable: the nodes travel beside it (`live.changes`, index for index), with their values before and after only for a listener that subscribed with `{ bodies: true }` — live values, which a listener that keeps them copies on delivery. Core reports where a value was staged (`AttributionHooks.staged`); the engine compares what each staged node showed before and after the drain, which is also how `OPTIMISTIC_REVERTED` is judged. `create` — a computation's creation run (`RerunEvent`'s shape without causes: `nodeId`, `nodeName`, `nodeKind`, `at`, `selfMs`, `totalMs`, `depCount`, `phase`, `held`, `interaction`) — the mount work that no `RerunEvent` describes, and what `InteractionEvent.created` counts. `effect` — an effect's callback, the imperative half that writes the DOM: `nodeId`, `nodeName`, `at`, `durationMs`, `run`, `interaction`. `flight` — an async node's flight, kickoff to landing: `nodeId`, `nodeName`, `ownerPath`, `at`, `durationMs`, `outcome: "landed" | "abandoned"` (superseded before it landed), `interaction`. `fallback` — a `<Loading>` boundary's fallback, displayed to hidden: `ownerPath`, `at`, `shownMs`, `interaction`. `at` is the end of the drain that rendered the swap — the boundary's swap is its output's value, which lands with its frame ([RFC 05](05-async-data.md#loading-on-prop-dependencies-that-show-the-fallback-again)) and is judged displayed by the same drain-end comparison as `flush.changes` (a mount outside any drain displays it as the mount returns; a fallback that renders the same value the output already showed — `null` over nothing — is no change, so no record) — and a swap cleared before that (the content landed before the frame did, or the commit's own sweep cleared it ahead of any effect — the `LOADING_ON_OUTSIDE_HOLD` shape) was never on screen and is no record. Every derived `ChangeRecord` on a re-run's cause chain also carries the `nodeId` of the memo it came from, so a consumer can walk the propagation of one write node by node.

**Joining an interaction to the browser's INP entry.** An `InteractionEvent.at` is the DOM event's `timeStamp` when the runtime dispatched it (compiled event bindings pass it; `OBSERVE.attribution.withInteraction({ …, at: e.timeStamp }, fn)` for a custom dispatcher), and `inputDelayMs` is the queueing from that moment to the handler's entry — the browser's input delay. The `PerformanceEventTiming` entry for the same event (a `PerformanceObserver` on `"event"`) has `startTime === interaction.at` on the same clock and carries `interactionId`, so the join is `entry.startTime === interaction.at`: `inputDelayMs` accounts for the entry's `processingStart − startTime`, `handlerMs` for its processing, and `settledMs` extends past its `duration` to when Solid had the screen right.

Expand Down
2 changes: 2 additions & 0 deletions packages/signals/src/attribution.prod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,9 @@ export type {
FallbackEvent,
FlightEvent,
FlightLink,
FlushChange,
FlushEvent,
FlushLive,
GraphEvent,
GraphSize,
HeldWrite,
Expand Down
2 changes: 2 additions & 0 deletions packages/signals/src/attribution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@ export type {
FallbackEvent,
FlightEvent,
FlightLink,
FlushChange,
FlushEvent,
FlushLive,
GraphEvent,
GraphSize,
HeldWrite,
Expand Down
20 changes: 5 additions & 15 deletions packages/signals/src/boundaries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,6 @@ import {
heldUnder,
joinFuture,
passLane,
passTx,
resolveTx,
schedule,
setPassLane,
txOf,
Expand Down Expand Up @@ -965,19 +963,11 @@ function fallback<T>(b: Boundary): T {
out._flags |= REACTIVE_LANE_READ;
b._lane = null;
}
// Observe: a loading boundary starts showing its fallback — the swap lands
// with the frame's transaction, or this drain commits it (lane work, or no
// transaction). A mount's synchronous first show has no drain of its own:
// one is scheduled so the engine's display instant (`flushEnd`) comes.
if (__OBSERVE__ && !b._fallback && b._type & STATUS_PENDING && attrHooks !== null) {
attrHooks.boundaryFallback(
b,
b._tree,
true,
passLane !== null ? null : flushTransaction !== null ? resolveTx(flushTransaction) : passTx
);
if (!globalQueue._running) schedule();
}
// Observe: a loading boundary starts showing its fallback. The swap is the
// output's value; the engine watches it and counts the fallback displayed
// when the screen shows it (a commit, a landing, a lane's reveal).
if (__OBSERVE__ && !b._fallback && b._type & STATUS_PENDING && attrHooks !== null)
attrHooks.boundaryFallback(b, b._tree, true, out);
b._fallback = true;
// Built in its own scope, like `Show`'s function child, once per show
// (D-39: the fallback is the display until a new answer replaces the
Expand Down
Loading
Loading