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/fix-pending-companion-lane-parent.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion packages/signals/docs/INTERNALS-ASYNC-STATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
11 changes: 7 additions & 4 deletions packages/signals/src/core/async.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;

Expand Down
95 changes: 95 additions & 0 deletions packages/signals/tests/pending-companion-lane-parent.test.ts
Original file line number Diff line number Diff line change
@@ -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<T>(ms: number, value?: T): Promise<T> {
return new Promise<T>(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<number, string[]>();
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<void>;
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"
]);
});
});
2 changes: 2 additions & 0 deletions packages/signals/tests/treeshake.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand Down
5 changes: 4 additions & 1 deletion scripts/size/.size-limit.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
},
{
Expand Down
Loading