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

Wake blocked readers when a conditional drops pending dependencies without changing its result, while preserving pending state from other dependency paths.
34 changes: 23 additions & 11 deletions packages/signals/src/core/async.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ function clearPendingSources(el: Computed<any>): void {
// SOURCE's subscribers, so it is retryable iff a tracked read created that
// edge: a dep that IS the source, or one whose own pending chain carries it
// (pending sources propagate the origin node, so this covers any depth).
// Dev-only caller — tree-shaken from prod builds.
// Also guards branch-local recovery: another dependency may still need the source.
function retryReaches(el: Computed<any>, source: any): boolean {
for (let d = el._deps; d; d = d._nextDep) {
const dep = ((d._dep as FirewallSignal<unknown>)._firewall || d._dep) as Computed<any>;
Expand Down Expand Up @@ -171,9 +171,9 @@ export function releaseSettledDependents(el: Computed<any>): void {
// object identity down the whole dependent tree, and holding it is exactly
// the "blocked on this error" marker — re-enqueue those holders so they
// re-run: fresh values commit and flow, and a dependent with another
// still-broken source simply re-errors. The async dimension needs no twin of
// its own: recovery there passes through a pending window whose re-runners
// set _blocked and ride settlePendingSource. Walks the full dependent graph
// still-broken source simply re-errors. Pending recovery uses
// settlePendingSource to clear inherited status and retry blocked readers.
// Walks the full dependent graph
// (releaseSettledDependents shape): identity holders can sit below an
// intermediate whose own error state has since been scrubbed or replaced
// (e.g. an error boundary's tree node).
Expand All @@ -193,9 +193,13 @@ export function settleErroredDependents(el: Computed<any>, error: any): void {
if (scheduled) schedule();
}

export function settlePendingSource(el: Computed<any>): void {
// Retire `source` from pending state along the dependent graph rooted at `el`.
// By default, `el` is the source whose flight settled or was superseded.
// With a distinct `source`, `el` is a recovered computation that dropped it:
// the source may still be pending, so dependents with another path to it stay pending.
export function settlePendingSource(el: Computed<any>, source: Computed<any> = el): void {
// Invariant: walking a settle implies truth exists. A caller reaching this
// with an uninitialized source is announcing a settle that has not
// with an uninitialized traversal root (`el`) is announcing a settle that has not
// happened — parked readers would wake into a value that was never
// produced (the rc.5 regression: the recompute-side walk fired on a
// projection driver whose first flight was superseded before any commit
Expand Down Expand Up @@ -233,17 +237,25 @@ export function settlePendingSource(el: Computed<any>): void {
});
}
}
// The normal landing path already cleared the source's own set. Superseded
// re-parks arrive here with an abandoned self entry, which must retire in
// the same walk as its propagated copies.
removePendingSource(el, el);
// Landing and branch recovery already cleared el's own set. Superseded
// re-parks can retain an abandoned self entry (source === el), which must
// retire in the same walk as its propagated copies.
removePendingSource(el, source);
let scheduled = false;
let released: Computed<any>[] | undefined;
const visited = new Set<Computed<any>>();
// Companion updates no-op without the verdict layer (null hook).
const updateCompanions = GlobalQueue._updatePendingSignal;
const settle = (node: Computed<any>) => {
if (visited.has(node) || !removePendingSource(node, el)) return;
if (visited.has(node)) return;
// A conditional dropped this source, but another dependency can still
// carry it. Only retire pending state inherited through the recovered
// branch. Deliberately NOT marked visited on this early return: the
// carrying dependency may itself be a later branch of this same walk
// (two unchanged memos converging), and its visit must be free to
// re-examine this node once that branch has retired the source.
if (source !== el && retryReaches(node, source)) return;
if (!removePendingSource(node, source)) return;
visited.add(node);
node._time = clock;
const remaining = node._x?._pendingSources?.values().next().value;
Expand Down
18 changes: 13 additions & 5 deletions packages/signals/src/core/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,10 +260,12 @@ export function recompute(el: Computed<any>, create: boolean = false): void {
el._x?._overrideValue !== NOT_PENDING &&
el._x?._overrideValue !== undefined;
const wasUninitialized = !!(el._statusFlags & STATUS_UNINITIALIZED);
// Outgoing error, captured before the compute clears status: if this run
// recovers to an unchanged value, dependents still holding this object must
// be swept (settleErroredDependents, #2949).
// Capture both error and pending status before the compute clears them.
// A conditional can drop its pending source and recover to an unchanged
// value, leaving blocked dependents outside that source’s settle walk.
const outgoingError = el._statusFlags & STATUS_ERROR ? el._x?._error : undefined;
const outgoingPendingSources =
el._statusFlags & STATUS_PENDING ? el._x?._pendingSources : undefined;
// Pending SOURCE-hood, captured before the compute clears status: a node
// whose own flight parked dependents self-registers in _pendingSources
// (notifyStatus, isSource). If this recompute supersedes that flight and
Expand Down Expand Up @@ -602,8 +604,14 @@ export function recompute(el: Computed<any>, create: boolean = false): void {
// in an errored run and may sit on stale commits (#2949). Changed-value
// recoveries ride insertSubs above; a comparator throw re-errored the node
// (el._x?._error re-set), so this only runs on a genuinely clean recovery.
if (outgoingError !== undefined && !valueChanged && !el._x?._error)
settleErroredDependents(el, outgoingError);
if (!valueChanged && !el._x?._error) {
if (outgoingError !== undefined) settleErroredDependents(el, outgoingError);
// Self-registration (this node's own superseded flight) is the #3181
// sweep's business below — retiring it here too would walk twice.
if (outgoingPendingSources)
for (const source of outgoingPendingSources)
if (source !== el) settlePendingSource(el, source);
}

// #3181: a synchronous settle supersedes the old landing callback, so
// recompute owns its pending-source sweep. An uninitialized node without
Expand Down
264 changes: 264 additions & 0 deletions packages/signals/tests/late-pending-equality.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,264 @@
import { expect, it } from "vitest";
import {
createMemo,
createProjection,
createRenderEffect,
createRoot,
createSignal,
flush,
isPending
} from "../src/index.js";

it("wakes a late reader when a conditional drops a pending source with an equal value", async () => {
const gate = Promise.withResolvers<{ enabled: boolean }>();
let start!: () => void;
let reveal!: () => void;
let setVirtual!: (v: boolean) => void;
let dispose!: () => void;
const values: unknown[] = [];
createRoot(d => {
dispose = d;
const [request, setRequest] = createSignal(false);
const [virtual, writeVirtual] = createSignal(false);
setVirtual = writeVirtual;
const [shown, setShown] = createSignal(false);
const data = createProjection(() => (request() ? gate.promise : { enabled: false }), {
enabled: false
});
const enabled = createMemo(() => !virtual() && data.enabled);
const handler = createMemo(() => (enabled() ? "handler" : undefined));
const attributes = createMemo(() => ({ handler: handler() }));
createRenderEffect(handler, () => {});
createRenderEffect(
() => (shown() ? attributes() : null),
value => {
values.push(value);
}
);
start = () => setRequest(true);
reveal = () => setShown(true);
});
flush();
try {
start();
flush();
reveal();
flush();
setVirtual(true);
flush();
expect(values).toEqual([null, { handler: undefined }]);
gate.resolve({ enabled: false });
await new Promise(resolve => setTimeout(resolve, 0));
flush();
expect(values).toEqual([null, { handler: undefined }]);
} finally {
gate.resolve({ enabled: false });
dispose();
flush();
}
});

it("does not rerun an unchanged dependent that only inherited pending status", async () => {
const gate = Promise.withResolvers<{ enabled: boolean }>();
let start!: () => void;
let stop!: () => void;
let dispose!: () => void;
let computes = 0;
const values: unknown[] = [];
createRoot(d => {
dispose = d;
const [request, setRequest] = createSignal(false);
const [virtual, setVirtual] = createSignal(false);
const data = createProjection(() => (request() ? gate.promise : { enabled: false }), {
enabled: false
});
const enabled = createMemo(() => !virtual() && data.enabled);
const attributes = createMemo(() => {
const value = enabled();
computes++;
return { enabled: value };
});
createRenderEffect(attributes, value => {
values.push(value);
});
start = () => setRequest(true);
stop = () => setVirtual(true);
});
flush();
try {
start();
flush();
stop();
flush();
gate.resolve({ enabled: false });
await new Promise(resolve => setTimeout(resolve, 0));
flush();
expect(computes).toBe(1);
expect(values).toEqual([{ enabled: false }]);
} finally {
gate.resolve({ enabled: false });
dispose();
flush();
}
});

it.each([false, true])(
"keeps a dependent pending through another path (indirect: %s)",
async indirect => {
const gate = Promise.withResolvers<{ enabled: boolean }>();
let start!: () => void;
let stop!: () => void;
let dispose!: () => void;
let pending!: () => boolean;
const values: unknown[] = [];
createRoot(d => {
dispose = d;
const [request, setRequest] = createSignal(false);
const [virtual, setVirtual] = createSignal(false);
const data = createProjection(() => (request() ? gate.promise : { enabled: false }), {
enabled: false
});
const enabled = createMemo(() => !virtual() && data.enabled);
const other = indirect ? createMemo(() => data.enabled) : () => data.enabled;
const combined = createMemo(() => ({ conditional: enabled(), direct: other() }));
pending = createMemo(() => isPending(combined));
createRenderEffect(combined, value => {
values.push(value);
});
createRenderEffect(pending, () => {});
start = () => setRequest(true);
stop = () => setVirtual(true);
});
flush();
try {
start();
flush();
expect(pending()).toBe(true);
stop();
flush();
expect(pending()).toBe(true);
expect(values).toEqual([{ conditional: false, direct: false }]);
gate.resolve({ enabled: true });
await new Promise(resolve => setTimeout(resolve, 0));
flush();
expect(pending()).toBe(false);
expect(values.at(-1)).toEqual({ conditional: false, direct: true });
} finally {
gate.resolve({ enabled: true });
dispose();
flush();
}
}
);

it.each([2, 3])("settles a branch immediately after it drops %i pending sources", async count => {
const gates = Array.from({ length: count }, () => Promise.withResolvers<{ enabled: boolean }>());
let start!: () => void;
let stop!: () => void;
let reveal!: () => void;
let pending!: () => boolean;
let dispose!: () => void;
const values: unknown[] = [];
createRoot(d => {
dispose = d;
const [request, setRequest] = createSignal(false);
const [virtual, setVirtual] = createSignal(false);
const [shown, setShown] = createSignal(false);
const data = gates.map(gate =>
createProjection(() => (request() ? gate.promise : { enabled: false }), { enabled: false })
);
const enabled = createMemo(() => !virtual() && data.map(value => value.enabled).some(Boolean));
const handler = createMemo(() => (enabled() ? "handler" : undefined));
const attributes = createMemo(() => ({ handler: handler() }));
pending = createMemo(() => isPending(handler));
createRenderEffect(handler, () => {});
createRenderEffect(pending, () => {});
createRenderEffect(
() => (shown() ? attributes() : null),
value => {
values.push(value);
}
);
start = () => setRequest(true);
stop = () => setVirtual(true);
reveal = () => setShown(true);
});
flush();
try {
start();
flush();
reveal();
flush();
expect(pending()).toBe(true);
stop();
flush();
expect(pending()).toBe(false);
expect(values).toEqual([null, { handler: undefined }]);
for (const gate of gates) gate.resolve({ enabled: true });
await new Promise(resolve => setTimeout(resolve, 0));
flush();
expect(pending()).toBe(false);
expect(values).toEqual([null, { handler: undefined }]);
} finally {
for (const gate of gates) gate.resolve({ enabled: true });
await new Promise(resolve => setTimeout(resolve, 0));
dispose();
flush();
}
});

it("settles converging unchanged branches before their shared source resolves", async () => {
const gate = Promise.withResolvers<{ enabled: boolean }>();
let start!: () => void;
let stop!: () => void;
let reveal!: () => void;
let pending!: () => boolean;
let dispose!: () => void;
const values: unknown[] = [];
createRoot(d => {
dispose = d;
const [request, setRequest] = createSignal(false);
const [virtual, setVirtual] = createSignal(false);
const [shown, setShown] = createSignal(false);
const data = createProjection(() => (request() ? gate.promise : { enabled: false }), {
enabled: false
});
const enabled = createMemo(() => !virtual() && data.enabled);
const left = createMemo(() => enabled());
const right = createMemo(() => enabled());
const combined = createMemo(() => ({ left: left(), right: right() }));
pending = createMemo(() => isPending(combined));
createRenderEffect(combined, () => {});
createRenderEffect(pending, () => {});
createRenderEffect(
() => (shown() ? combined() : null),
value => {
values.push(value);
}
);
start = () => setRequest(true);
stop = () => setVirtual(true);
reveal = () => setShown(true);
});
flush();
try {
start();
flush();
reveal();
flush();
expect(pending()).toBe(true);
expect(values).toEqual([null]);
stop();
flush();
expect(pending()).toBe(false);
expect(values).toEqual([null, { left: false, right: false }]);
gate.resolve({ enabled: true });
await new Promise(resolve => setTimeout(resolve, 0));
flush();
expect(values).toEqual([null, { left: false, right: false }]);
} finally {
gate.resolve({ enabled: true });
dispose();
flush();
}
});
5 changes: 4 additions & 1 deletion packages/signals/tests/treeshake.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,10 @@ describe("pay-for-use tree-shaking (#2883)", () => {
// The lane predicate itself (`resolveLane`) shakes out. On the #3337
// stack the same fixes measured +485 B (22,381 -> 22,866). Measured at
// 22,457; 43 bytes of headroom.
expect(minifiedBytes).toBeLessThan(22_500);
// Conditional pending recovery adds 191 B over next at b5bd6fba
// (22,457 → 22,648 B), including the alternate dependency path guard
// and the self-source skip that leaves the #3181 sweep as the one walk.
expect(minifiedBytes).toBeLessThan(22_700);
});

it("plain stores shed the verdict layer, affects, boundaries, and map", async () => {
Expand Down
Loading
Loading