diff --git a/.changeset/fix-pending-companion-lane-parent.md b/.changeset/fix-pending-companion-lane-parent.md new file mode 100644 index 000000000..804bf9417 --- /dev/null +++ b/.changeset/fix-pending-companion-lane-parent.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +`isPending(details)` reports the load of an optimistic value when `details` derives it through an async memo (#3379). `notifyStatus` now assigns the node's optimistic lane before poking its companions, so a companion lane created by the poke is parented to the node's lane: the indicator effect flushes on the companion's child lane immediately instead of merging it into the held lane and waiting on the async it reports. diff --git a/packages/signals/docs/INTERNALS-ASYNC-STATE.md b/packages/signals/docs/INTERNALS-ASYNC-STATE.md index 3ada59cfc..8592ba14b 100644 --- a/packages/signals/docs/INTERNALS-ASYNC-STATE.md +++ b/packages/signals/docs/INTERNALS-ASYNC-STATE.md @@ -82,7 +82,7 @@ Semantics of the `(_pendingValue, _overrideValue)` pair for an optimistic node ## 2. Lanes (`lanes.ts`) - One lane per optimistic _source signal_ (`signalLanes` WeakMap), reused across writes to the same signal. Union-find merging (`_mergedInto`); merges move `_pendingAsync` and effect queues into the root. -- `_parentLane`: companion nodes (`_pendingSignal`/`_latestValueComputed`) get _child_ lanes that intentionally do **not** merge with the parent (`assignOrMergeLane` parent/child carve-out) so `isPending` effects can flush before the parent's async settles. +- `_parentLane`: companion nodes (`_pendingSignal`/`_latestValueComputed`) get _child_ lanes that intentionally do **not** merge with the parent (`assignOrMergeLane` parent/child carve-out) so `isPending` effects can flush before the parent's async settles. The parent is read from the owner's `_optimisticLane` when the companion lane is created (`getOrCreateLane`), so every path that assigns a node's lane and pokes its companions must assign first: `notifyStatus` does (#3379 — a memo made pending by propagation before it rode the lane got a parentless companion lane, which the indicator's other dep merged into the held lane; `isPending` then waited on the async it reports). - Lane lifecycle: created on optimistic write → nodes join via `insertSubs(node, true)` → `assignOrMergeLane` → lane-routed effects run when the lane is not held (`runLaneEffects` → `laneHeld`) → cleaned up by `cleanupCompletedLanes` when the owning transition completes (or when orphaned, `_transition === null`). - `_pendingAsync` add/delete sites: added in `recompute`'s async catch under a lane (core.ts ~264), removed on async resolution (`asyncWrite`, async.ts ~214) and on lane-corrected recompute (core.ts ~254). The set records the async the lane _owns_, not what holds it. - Replay gating (#3330): `laneReadsCommitted` hands a lane reader the committed `_value` of a staged node and records the reader in the batch's `_gatedSubs` for a re-run at commit — only when `_pendingValue !== _value`. A lane recompute that already published the value (INV-11) leaves the two equal; recording the reader anyway replayed its effects against an unchanged frame. diff --git a/packages/signals/src/core/async.ts b/packages/signals/src/core/async.ts index dadf6793b..235a4b709 100644 --- a/packages/signals/src/core/async.ts +++ b/packages/signals/src/core/async.ts @@ -920,6 +920,13 @@ export function notifyStatus( const startsBlocking = isOptimisticBoundary && hasActiveOverride(el); if (!blockStatus) { + // Lane before companions: the companion pokes below may create the + // node's pending-signal lane, whose parent is read from the node's lane + // at creation. Assigned after them (as it was), a node made pending by + // propagation before it rode the lane got a parentless companion lane, + // and the isPending reader that also depends on the node merged it into + // the held lane — the verdict then waited on the async it reports (#3379). + if (lane) assignOrMergeLane(el, lane); if (status === STATUS_PENDING && pendingSource) { addPendingSource(el, pendingSource); // A fresh flight from a settled state starts with its inputs unpublished @@ -945,10 +952,6 @@ export function notifyStatus( GlobalQueue._updateChildCompanions(el); } - if (lane && !blockStatus) { - assignOrMergeLane(el, lane); - } - const downstreamBlockStatus = blockStatus || startsBlocking; const downstreamLane = blockStatus || isOptimisticBoundary ? undefined : lane; diff --git a/packages/signals/tests/pending-companion-lane-parent.test.ts b/packages/signals/tests/pending-companion-lane-parent.test.ts new file mode 100644 index 000000000..9001d99ca --- /dev/null +++ b/packages/signals/tests/pending-companion-lane-parent.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vitest"; +import { + action, + createMemo, + createOptimistic, + createRenderEffect, + createRoot, + flush, + isPending +} from "../src/index.js"; + +// Manual clock (see async-chain-supersession.test.ts). +let now = 0; +let timers: { at: number; run: () => void }[] = []; +function delay(ms: number, value?: T): Promise { + return new Promise(r => timers.push({ at: now + ms, run: () => r(value as T) })); +} +async function settle() { + for (let r = 0; r < 3; r++) { + for (let i = 0; i < 10; i++) await Promise.resolve(); + flush(); + } +} +async function advanceTo(t: number) { + while (true) { + timers.sort((a, b) => a.at - b.at); + const next = timers[0]; + if (!next || next.at > t) break; + timers.shift(); + now = next.at; + next.run(); + await settle(); + } + now = t; + await settle(); +} +function frames(log: string[], when: number[]): string[] { + const byTime = new Map(); + log.forEach((v, i) => (byTime.get(when[i]) ?? byTime.set(when[i], []).get(when[i])!).push(v)); + return [...byTime].map(([t, vs]) => `${t}: ${vs.sort().join(" | ")}`); +} +function text(fn: () => string, log: string[], when: number[]) { + let last: string | undefined; + createRenderEffect(fn, v => { + if (v !== last) { + last = v; + log.push(v); + when.push(now); + } + }); +} + +describe("isPending() of an async memo over an optimistic derivation", () => { + // The companion's child lane takes its parent from the owner's lane at + // creation. `details` is made pending by propagation (copy's flight) before + // it rides the lane itself, and notifyStatus poked its companion before + // assigning its lane: the companion lane was born parentless, the indicator + // effect (which also depends on details) merged it into the held `value` + // lane, and its run waited on the async it reports. + it("#3379 isPending(details) is true while details loads the optimistic value through an async memo", async () => { + now = 0; + timers = []; + const log: string[] = []; + const when: number[] = []; + let update!: () => Promise; + createRoot(() => { + const [value, setValue] = createOptimistic(0); + const copy = createMemo(async () => value()); + const details = createMemo(() => delay(1000, copy())); + update = action(function* () { + setValue(1); + yield delay(2000); + }); + text(() => `View: ${value()} / ${details()}`, log, when); + text(() => `Pending: ${isPending(details)}`, log, when); + }); + flush(); + await settle(); + await advanceTo(3000); + update(); + await settle(); + await advanceTo(6000); + // The View effect reads details, so it holds the frame while details + // loads (0 / 0 until the derivation lands); the indicator must not — it + // rides the companion's child lane and reports the load as it starts, + // exactly as it does during the revert (5000). + expect(frames(log, when)).toEqual([ + "1000: Pending: false | View: 0 / 0", + "3000: Pending: true", + "4000: Pending: false | View: 1 / 1", + "5000: Pending: true", + "6000: Pending: false | View: 0 / 0" + ]); + }); +}); diff --git a/packages/signals/tests/treeshake.test.ts b/packages/signals/tests/treeshake.test.ts index fa519049f..4b0f23619 100644 --- a/packages/signals/tests/treeshake.test.ts +++ b/packages/signals/tests/treeshake.test.ts @@ -250,6 +250,8 @@ describe("pay-for-use tree-shaking (#2883)", () => { // (`wokenTransitions`, deduped), and a lane recompute (OPT-dirty, // override or not) drops the transaction hold it supersedes. +58 B // (22,915 → 22,973). + // Companion lane parented (#3379): `notifyStatus` assigns the node's lane + // before poking its companions — a reorder, -4 B (22,973 → 22,969). expect(minifiedBytes).toBeLessThan(23_050); }); diff --git a/scripts/size/.size-limit.js b/scripts/size/.size-limit.js index 82c935ed8..7c7fd0412 100644 --- a/scripts/size/.size-limit.js +++ b/scripts/size/.size-limit.js @@ -422,7 +422,10 @@ module.exports = [ // 15340 (+73 — the core seams, see the core floor note). // #3372/#3377 (2026-09-12): 15.45 -> 15.50 KB, measured at 15485 B against 15427 // (+58); see the core floor note. - limit: "15.50 KB", + // Companion lane parented (#3379, 2026-09-12): 15.50 -> 15.55 KB, measured + // at 15522 B against 15485 (+37 brotli on a -4 B minified reorder — the + // statement moved across a block boundary; noise, not weight). + limit: "15.55 KB", modifyEsbuildConfig }, {