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/optimistic-reverted.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/signals": patch
---

`OPTIMISTIC_REVERTED` (info, responsiveness): an optimistic value the screen showed was replaced by a different one — reverted at settle, or superseded by the truth — with the source and both values. `AttributionHooks.optimisticReverted` is the seam; `optimisticReverts: false` disables it. The runtime's own optimistic nodes (`isPending`/`latest` companions, derived overrides) are never judged.
19 changes: 17 additions & 2 deletions documentation/plans/responsiveness-findings-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,14 @@ any pre-await write opens the hold path, which `SILENT_HOLD` judges.
once and its interaction record's `settledMs` covers the await; the same
handler with `isPending(source)` read in JSX fires nothing.

### 2. Stamp the browser's `interactionId` on the interaction
### 2. Stamp the browser's `interactionId` on the interaction — SATISFIED BY #3580

`interactionId` lives on the `PerformanceEventTiming` entry, delivered by a
`PerformanceObserver` after the fact; it is not readable during dispatch, so
a stamp was never available. #3580 made `InteractionEvent.at` the event's
`timeStamp`, which equals the entry's `startTime`: the join is by equality
on `at`. Nothing further to do in the runtime; the adapter recipe belongs
in the docs.

- **Known:** the web runtime is inside the event dispatch when it opens
the frame; Event Timing (`PerformanceEventTiming.interactionId`) assigns
Expand All @@ -112,7 +119,15 @@ any pre-await write opens the hold path, which `SILENT_HOLD` judges.
- **Proof:** a click whose Event Timing entry reports 480 ms produces an
interaction record with the same id and a hold that names the blocker.

### 3. Optimistic reverts
### 3. Optimistic reverts — LANDED (signals; stores follow)

Shipped as `AttributionHooks.optimisticReverted` (two sites in
`optimistic.ts`: supersession, and the drop at settle) and
`OPTIMISTIC_REVERTED` (see RFC 08). Left for a follow-up: optimistic
_stores_ (the overlay folds off per path in `_clearOptimisticStores`), and
naming the interaction that wrote the guess — optimistic writes bypass the
`write` hook today, so the node carries no origin stamp; stamping them
touches the interaction accounting and is its own change.

- **Known:** the optimistic lane knows the shown value and the settled
value; `asyncEnd`'s `prev`/`value` and the lane commit see both.
Expand Down
129 changes: 68 additions & 61 deletions documentation/solid-2.0/08-dev-diagnostics.md

Large diffs are not rendered by default.

15 changes: 15 additions & 0 deletions packages/signals/src/core/attribution-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,21 @@ export interface AttributionHooks {
shown: boolean,
transition?: Transition | null
): void;
/**
* An optimistic override the screen displayed is being replaced by a
* different value. `"superseded"`: a new authoritative value landed that
* differs from the guess (tracked readers re-derive to it). `"reverted"`:
* nothing new landed and the guess lifts back to the committed value it
* covered (the action failed, or never wrote what it promised). `shown`
* is the override as displayed. Fired when the two differ by identity;
* the engine applies the node's own equality before judging.
*/
optimisticReverted(
el: Signal<any> | Computed<any>,
shown: unknown,
truth: unknown,
how: "superseded" | "reverted"
): void;
/**
* The one query on the surface: the provenance a root write performed at
* this moment would be stamped with — the innermost open frame (an effect
Expand Down
59 changes: 59 additions & 0 deletions packages/signals/src/core/attribution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -496,6 +496,15 @@ export interface AttributionOptions {
* and every one of them waited on the same source. `false` disables.
*/
stackedHolds?: { count: number } | false;
/**
* Optimistic-revert finding: emit OPTIMISTIC_REVERTED (`info`) when an
* optimistic value the screen showed is replaced by a different one —
* reverted at settle, or superseded by the truth (default true). The
* runtime's own optimistic nodes — `isPending`/`latest` companions and
* derived overrides — are never judged: they are the acknowledgement
* machinery, not a guess the person saw. `false` disables.
*/
optimisticReverts?: boolean;
}

/** A fallback shown for less than this is a flash: feedback for a wait too short to need it. */
Expand Down Expand Up @@ -572,6 +581,7 @@ const defaultOptions = {
graphGrowth: { visits: 3, ratio: 1.25 } as { visits: number; ratio: number } | false,
abandonedFlights: { count: 3, windowMs: 1000 } as { count: number; windowMs: number } | false,
fallbackFlashes: true,
optimisticReverts: true,
stackedHolds: { count: 3 } as { count: number } | false
};
let options: typeof defaultOptions = { ...defaultOptions };
Expand Down Expand Up @@ -2908,6 +2918,52 @@ function checkLongHold(event: HoldEvent, subject: Signal<any>): void {
if (severity === "warn") reportDiagnostic(entry);
}

/**
* The person saw the guess, then the correction. An optimistic value is a
* promise the UI makes about the outcome; when the outcome differs — the
* action failed and the override lifted back to the old value, or the
* source answered with something else — the screen changes twice for one
* intent. Expected on failure and correct by construction (the override
* reverts; that is the feature), so `info`: a count that grows for one
* source is what says the guess, or the failure rate, is wrong. Judged by
* the node's own equality, so a structurally equal replacement is not a
* revert.
*/
function checkOptimisticRevert(
el: Signal<any> | Computed<any>,
shown: unknown,
truth: unknown,
how: "superseded" | "reverted"
): void {
// The runtime's own optimistic nodes are not guesses the person saw: an
// `isPending()` companion 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. Same predicate the hold
// census uses to skip them.
if (!options.optimisticReverts || isCompanion(el)) return;
const equals = (el as { _equals?: false | ((a: unknown, b: unknown) => boolean) })._equals;
if (equals && equals(shown, truth)) return;
const source = nodeName(el);
const message =
`[OPTIMISTIC_REVERTED] the optimistic value of ${source} showed ${preview(shown)}; it ` +
`${how === "superseded" ? "settled to" : "reverted to"} ${preview(truth)}. 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.`;
emitDiagnostic(
{
code: "OPTIMISTIC_REVERTED",
kind: "responsiveness",
severity: "info",
message,
nodeName: source,
data: { source, shown: preview(shown), truth: preview(truth), how }
},
el
);
}

// --- Graph growth -----------------------------------------------------------------

/** Per route: the graph's size at its last `visits` settles, oldest first. */
Expand Down Expand Up @@ -4129,6 +4185,9 @@ const engineHooks: AttributionHooks = {
// The folds hear the show at its display (see trackFallback), not here.
trackFallback(boundary, tree, shown, transition ?? null);
},
optimisticReverted(el, shown, truth, how) {
checkOptimisticRevert(el, shown, truth, how);
},
currentOrigin() {
return ambientOrigin();
}
Expand Down
1 change: 1 addition & 0 deletions packages/signals/src/core/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ export type DiagnosticCode =
| "ABANDONED_FLIGHTS"
| "FALLBACK_FLASH"
| "STACKED_HOLDS"
| "OPTIMISTIC_REVERTED"
| "EFFECT_WRITES_OWN_SOURCE"
| "EFFECT_RELAY_TEAR"
| "IMMUTABLE_UPDATE_IN_STORE"
Expand Down
18 changes: 17 additions & 1 deletion packages/signals/src/core/optimistic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,13 @@ function resolveOptimisticNodes(nodes: OptimisticNode[]): void {
node._value !== unwrapOverride(prevOverride)
) {
if (derived) node._value = unwrapOverride(prevOverride);
else insertSubs(node, true);
else {
// The guess lifts and what was beneath it differs: the screen
// changes from the override to the committed value.
if (__OBSERVE__ && attrHooks !== null)
attrHooks.optimisticReverted(node, unwrapOverride(prevOverride), node._value, "reverted");
insertSubs(node, true);
}
}
node._transition = null;
if (node._x !== null) node._x._overrideOwner = null;
Expand Down Expand Up @@ -323,6 +329,16 @@ function supersedeOverride(el: OptimisticNode, value: unknown): void {
// 0 is mainline (no action): always the current question.
if (origin && origin < el._x!._overrideStamp) return;
el._config |= CONFIG_OVERRIDE_SUPERSEDED;
// A fresh landing is staged in `_pendingValue` (landOnOverride) or is the
// truth endOptimism read from it; the committed value with nothing staged
// is the value the guess covered — the screen goes back, not forward.
if (__OBSERVE__ && attrHooks !== null)
attrHooks.optimisticReverted(
el,
unwrapOverride(el._x!._overrideValue),
value,
el._pendingValue === NOT_PENDING && value === el._value ? "reverted" : "superseded"
);
const lane = el._x?._optimisticLane;
if (lane) {
const root = findLane(lane);
Expand Down
Loading
Loading