Describe the bug
yield until(predicate) inside an action() never resolves when the predicate reads the same createOptimisticStore a live stream feeds and the confirming frame landed before until() was called. With a timeout it rejects with TimeoutError; without one the action hangs forever.
This is the single-primitive shape the until() docstring recommends (optimistic row + socket echo), and it works when the frame lands after until() subscribed. The ordering that breaks is the natural one for a server that broadcasts to subscribers before it answers the mutation.
Regression. Bisected to d80cd1f "fix(signals): nodes created mainline during a hold are born held (A29 creation-time form) (#3451)". Its parent 61a114c passes, published @solidjs/signals@2.0.0-rc.8 passes, current next (37ea885) fails.
Mechanism. The action's transaction holds the staged frame under its optimism. until() creates its predicate effect from mainline code (after the await), so with #3451 that effect is born held: its synchronous first run is skipped and it is added to the transaction's _gatedSubs to be replayed at commit. But the commit is the action's settle, which is exactly what the until() promise is holding open. Deadlock.
CONFIG_DIRECT_COMMIT exists for this case (its comment: "a staged value with an immediate apply ... deadlocks the hold (until)"), but recompute's direct-commit branch is gated on bornHeld === null, so the born-held path pre-empts it.
Your Example Website or App
Vitest test against packages/signals (below). No app or extra dependencies needed. I hit it in a real app: a seat-reservation demo where a live() server function streams the seat map into an optimistic store and a reserve action does await reserveSeat(); yield until(() => data.reservations.some(r => r.id === saved.id)).
Steps to Reproduce the Bug or Issue
- Check out
next.
- Save the test below as
packages/signals/tests/until-held-frame-before-call.test.ts.
cd packages/signals && pnpm vitest run tests/until-held-frame-before-call.test.ts
- It fails with
expected TimeoutError ... to not be an instance of TimeoutError. Push the confirming frame after answer(...) instead and it passes.
import { expect, test } from "vitest";
import { action, createOptimisticStore, createRenderEffect, createRoot, flush, TimeoutError, until } from "../src/index.js";
type Row = { id: string; status: "pending" | "confirmed" };
type Snapshot = { rows: Row[] };
const settle = async (n = 3) => {
for (let i = 0; i < n; i++) {
await new Promise(r => setTimeout(r, 0));
flush();
}
};
// A manually pumped AsyncIterable — what a live() server-function stream
// materializes as on the client.
function stream<T>() {
const buffered: IteratorResult<T>[] = [];
let waiter: ((r: IteratorResult<T>) => void) | null = null;
const iterable: AsyncIterable<T> = {
[Symbol.asyncIterator]: () => ({
next: () =>
new Promise<IteratorResult<T>>(res => {
if (buffered.length) res(buffered.shift()!);
else waiter = res;
}),
return: () => Promise.resolve({ done: true as const, value: undefined })
})
};
return {
iterable,
push(value: T) {
const r = { done: false as const, value };
if (waiter) {
const w = waiter;
waiter = null;
w(r);
} else buffered.push(r);
}
};
}
test("until() sees a confirming frame that landed on the held store before until() was called", async () => {
const feed = stream<Snapshot>();
let answer!: (row: Row) => void;
const mutation = new Promise<Row>(res => (answer = res));
const views: string[] = [];
let reserve!: () => Promise<unknown>;
createRoot(() => {
const [store, setStore] = createOptimisticStore<Snapshot>(() => feed.iterable, { rows: [] }, { key: "id" });
reserve = action(async function* () {
setStore(d => {
d.rows.push({ id: "temp", status: "pending" });
});
const saved = await mutation;
yield until(() => store.rows.some(r => r.id === saved.id), { timeout: 200 });
});
createRenderEffect(
() => store.rows.map(r => `${r.id}:${r.status}`).join(",") || "empty",
v => {
views.push(v);
}
);
});
feed.push({ rows: [] });
await settle();
expect(views.at(-1)).toBe("empty");
const done = reserve().then(
() => "settled",
e => e
);
await settle();
expect(views.at(-1)).toBe("temp:pending");
// Server mutates and broadcasts first — the frame lands while the action
// is still awaiting the mutation's answer, and is held under its optimism.
feed.push({ rows: [{ id: "res_1", status: "confirmed" }] });
await settle();
expect(views.at(-1)).toBe("temp:pending");
// Then it answers, and the action reaches its until().
answer({ id: "res_1", status: "confirmed" });
await settle();
const outcome = await done;
expect(outcome).not.toBeInstanceOf(TimeoutError);
expect(outcome).toBe("settled");
await settle();
expect(views.at(-1)).toBe("res_1:confirmed");
});
Expected behavior
The held frame is the confirmation the predicate names, so until() should flip and the action should settle, revealing the frame with the overlay revert (the same outcome as when the frame lands after until() subscribed).
Screenshots or Videos
No response
Platform
- OS: macOS
- Node: 25.1.0
@solidjs/signals: next @ 37ea885 (fails), 3fc2c54 (fails), d80cd1f (first bad), 61a114c (passes), 2.0.0-rc.8 published (passes)
Additional context
Proposed fix (one early return in enterStagedRead, mainline branch, after the _verdictPull check). A direct-commit reader is the documented tunnel through a hold and applies on its own microtask, so it should be neither entered nor born held, the same exemption verdict pulls and optimistic nodes already get:
if (GlobalQueue._verdictPull) return;
+ // A promise-delivery effect (resolve()/until(), CONFIG_DIRECT_COMMIT)
+ // reads staged truth by contract — it is the tunnel that keeps a hold
+ // deadlock-free — and applies on its own microtask, so it is neither
+ // entered nor born held. Born held, an until() created mainline after
+ // the confirming frame was staged would replay only at the commit its
+ // own promise is holding open (#3451 follow-up).
+ if (ctx._config & CONFIG_DIRECT_COMMIT) return;
if (
ctx._flags & REACTIVE_RECOMPUTING_DEPS &&
With that change the test above passes, and so do until, until-entanglement, born-held, resolve, refresh-await, superseded-before-first-commit, visibility-oracle and the rest of the signals suite (including dist-artifacts after a rebuild). Happy to open a PR with the test and the fix.
Workaround in the meantime: settle the action with await mutation; yield refresh(store) instead of until(); the refreshed answer stages into the held transaction and reveals atomically with the overlay revert.
Describe the bug
yield until(predicate)inside anaction()never resolves when the predicate reads the samecreateOptimisticStorea live stream feeds and the confirming frame landed beforeuntil()was called. With atimeoutit rejects withTimeoutError; without one the action hangs forever.This is the single-primitive shape the
until()docstring recommends (optimistic row + socket echo), and it works when the frame lands afteruntil()subscribed. The ordering that breaks is the natural one for a server that broadcasts to subscribers before it answers the mutation.Regression. Bisected to d80cd1f "fix(signals): nodes created mainline during a hold are born held (A29 creation-time form) (#3451)". Its parent 61a114c passes, published
@solidjs/signals@2.0.0-rc.8passes, currentnext(37ea885) fails.Mechanism. The action's transaction holds the staged frame under its optimism.
until()creates its predicate effect from mainline code (after theawait), so with #3451 that effect is born held: its synchronous first run is skipped and it is added to the transaction's_gatedSubsto be replayed at commit. But the commit is the action's settle, which is exactly what theuntil()promise is holding open. Deadlock.CONFIG_DIRECT_COMMITexists for this case (its comment: "a staged value with an immediate apply ... deadlocks the hold (until)"), butrecompute's direct-commit branch is gated onbornHeld === null, so the born-held path pre-empts it.Your Example Website or App
Vitest test against
packages/signals(below). No app or extra dependencies needed. I hit it in a real app: a seat-reservation demo where alive()server function streams the seat map into an optimistic store and a reserve action doesawait reserveSeat(); yield until(() => data.reservations.some(r => r.id === saved.id)).Steps to Reproduce the Bug or Issue
next.packages/signals/tests/until-held-frame-before-call.test.ts.cd packages/signals && pnpm vitest run tests/until-held-frame-before-call.test.tsexpected TimeoutError ... to not be an instance of TimeoutError. Push the confirming frame afteranswer(...)instead and it passes.Expected behavior
The held frame is the confirmation the predicate names, so
until()should flip and the action should settle, revealing the frame with the overlay revert (the same outcome as when the frame lands afteruntil()subscribed).Screenshots or Videos
No response
Platform
@solidjs/signals:next@ 37ea885 (fails), 3fc2c54 (fails), d80cd1f (first bad), 61a114c (passes), 2.0.0-rc.8 published (passes)Additional context
Proposed fix (one early return in
enterStagedRead, mainline branch, after the_verdictPullcheck). A direct-commit reader is the documented tunnel through a hold and applies on its own microtask, so it should be neither entered nor born held, the same exemption verdict pulls and optimistic nodes already get:With that change the test above passes, and so do
until,until-entanglement,born-held,resolve,refresh-await,superseded-before-first-commit,visibility-oracleand the rest of the signals suite (includingdist-artifactsafter a rebuild). Happy to open a PR with the test and the fix.Workaround in the meantime: settle the action with
await mutation; yield refresh(store)instead ofuntil(); the refreshed answer stages into the held transaction and reveals atomically with the overlay revert.