Repository navigation
Description
On next @ ecf3011. Found by the ReactiveScript timing fuzzer (issue tracker app, Solid arm), reduced to plain Solid.
A list store is asked by a filter. The filter changes and the new question fails, so the Errored boundary shows the error. An action then guesses on the store (removes a row). That re-asks the question; when the answer ([#2]) lands, the screen shows the previous question's rows ([#1][#3][#4]), without the guess applied, under the new filter. It stays wrong until the action's own refresh lands.
/**
* @jsxImportSource @solidjs/web
* @vitest-environment jsdom
*/
import { test } from "vitest";
import { For, Loading, Errored, action, createOptimisticStore, createSignal, flush, refresh } from "solid-js";
import { render } from "../src/index.js";
const tick = async () => { for (let i = 0; i < 4; i++) { await new Promise(r => setTimeout(r, 0)); flush(); } };
test("guess on an errored store brings back the previous question's answer", async () => {
const asks: { key: string; res: (v: any) => void; rej: (e: any) => void }[] = [];
const ask = (key: string) => new Promise<any>((res, rej) => asks.push({ key, res, rej }));
const settle = async (key: string, f: (a: any) => void) => { const i = asks.findIndex(a => a.key === key); f(asks.splice(i, 1)[0]); await tick(); };
const container = document.body.appendChild(document.createElement("div"));
const frames: string[] = []; const snap = (l: string) => frames.push(`${l}: ${container.textContent} air ${asks.map(a => a.key)}`);
let setFilter!: (f: string) => void; let closeOne!: (id: number) => any;
render(() => {
const [filter, sf] = createSignal("open"); setFilter = sf;
const [issues, set] = createOptimisticStore<any[]>(() => ask(`issues:${filter()}`), [], { key: "id" });
closeOne = action(function* (id: number) {
set(list => { const i = list.findIndex(t => t.id === id); if (i >= 0) list.splice(i, 1); });
yield ask(`close:${id}`);
refresh(issues);
});
return <Loading fallback={<p>loading</p>}><Errored fallback={e => <p>error {e().message}</p>}><p>{filter()}: <For each={issues}>{(it: any) => <i>[#{it.id}]</i>}</For></p></Errored></Loading>;
}, container);
await tick();
await settle("issues:open", a => a.res([{ id: 1 }, { id: 3 }, { id: 4 }])); snap("loaded");
setFilter("closed"); await tick(); snap("filter closed");
await settle("issues:closed", a => a.rej(new Error("declined"))); snap("closed failed");
closeOne(1); await tick(); snap("close #1");
await settle("issues:closed", a => a.res([{ id: 2 }])); snap("closed answered");
await settle("close:1", a => a.res({})); snap("close accepted");
await settle("issues:closed", a => a.res([{ id: 1 }, { id: 2 }])); snap("refresh answered");
console.log(frames.join("\n"));
});Actual:
loaded: open: [#1][#3][#4]
filter closed: open: [#1][#3][#4]
closed failed: error declined
close #1: error declined air close:1,issues:closed
closed answered: closed: [#1][#3][#4] <- the open answer, no guess, labelled "closed"
close accepted: closed: [#1][#3][#4]
refresh answered: closed: [#1][#2]
Expected at closed answered: the closed answer with the guess on it (closed: [#2]), or a hold on the error until the action ends. The open answer's rows should not come back once the closed question has been asked. Same frames with <Loading on={filter()}>.
Activity
perf(signals): keep an error boundary's recovery linear in its readers (
fix(signals): keep an error boundary's fallback until a held list rec…
ryansolid commentedon Oct 31, 2026
Fixed on
nextby #4532 (3a73a36); it ships in the next RC.Generated by Claude Code