Repository navigation
Description
There are two stores: a keyed list whose derive awaits its fetch, and an unkeyed detail store. An action removes row #3 from the list by guess, and its write is still out. A second action then starts and puts a guess on the detail store only. At that moment the list shows #3 again. The row stays visible after the close is accepted and is gone only once the list's refresh answers.
Found at: next 5e68925, and still present at next ab3d2ad (which has #4458).
How it was found: a timing fuzzer over ReactiveScript apps. In an issue list, a closed issue reappeared when you renamed it while the close was out.
Repro (packages/web/test)
/**
* @jsxImportSource @solidjs/web
* @vitest-environment jsdom
*/
import { test } from "vitest";
import { For, Loading, Errored, action, createOptimisticStore, createMemo, flush, refresh, untrack } 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("a removal guess survives another action's guess on another store", async () => {
const asks: { key: string; res: (v: any) => void }[] = [];
const ask = (key: string) => new Promise<any>(res => asks.push({ key, res }));
const settle = async (key: string, v: any) => { const i = asks.findIndex(a => a.key === key); asks.splice(i, 1)[0].res(v); await tick(); };
const container = document.body.appendChild(document.createElement("div"));
const frames: string[] = []; const snap = (l: string) => frames.push(`${l}: ${container.textContent}`);
let rename!: (id: number, t: string) => any; let closeOne!: (id: number) => any;
render(() => {
const [issues, set] = createOptimisticStore(async (d: any[]) => { const r = await ask("issues"); d.length = r.length; r.forEach((x: any, i: number) => { d[i] = x; }); }, [] as any[], { key: "id" });
const detailQ = createMemo(() => ask("detail"));
const [detail, setDetail] = createOptimisticStore(async (d: any) => { Object.assign(d, detailQ()); }, {} as any);
rename = action(function* (id: number, t: string) { // guesses the detail only, once it has loaded
let shown: any = null; try { shown = untrack(() => detail.id); } catch {}
if (shown === id) setDetail((d: any) => { d.title = t; });
yield ask(`rename:${id}`);
refresh(issues);
});
closeOne = action(function* (id: number) { // removes the row by guess
const next = untrack(() => issues.filter((x: any) => x.id !== id));
set((d: any) => { d.length = next.length; next.forEach((x: any, i: number) => { d[i] = x; }); });
yield ask(`close:${id}`);
refresh(issues);
});
return <>
<Loading fallback={<p>loading</p>}><For each={issues}>{(it: any) => <i>[#{it.id} {it.title}]</i>}</For></Loading>
{" | "}<Loading fallback={<p>loading detail</p>}><Errored fallback={() => <p>err</p>}><u>#{detail.id} {detail.title}</u></Errored></Loading>
</>;
}, container);
await tick();
const rows = (t3: string) => [{ id: 1, title: "Login" }, { id: 3, title: t3 }, { id: 4, title: "Typo" }];
await settle("issues", rows("Crash")); snap("loaded");
rename(3, "Alpha"); await tick(); snap("rename #3"); // detail still loading: no guess
closeOne(3); await tick(); snap("close #3");
await settle("rename:3", {}); snap("rename accepted");
await settle("detail", { id: 3, title: "Alpha" }); snap("detail landed");
await settle("issues", rows("Alpha")); snap("refresh answered"); // the server has not closed #3 yet
rename(3, "Alpha"); await tick(); snap("rename #3 again"); // a guess on the detail store only
await settle("close:3", {}); snap("close accepted");
console.log(frames.join("\n"));
});Output
loaded: [#1 Login][#3 Crash][#4 Typo] | loading detail
rename #3: [#1 Login][#3 Crash][#4 Typo] | loading detail
close #3: [#1 Login][#4 Typo] | loading detail
rename accepted: [#1 Login][#4 Typo] | loading detail
detail landed: [#1 Login][#4 Typo] | loading detail
refresh answered: [#1 Login][#4 Typo] | #3 Alpha
rename #3 again: [#1 Login][#3 Alpha][#4 Typo] | #3 Alpha <- the close's removal is gone
close accepted: [#1 Login][#3 Alpha][#4 Typo] | #3 Alpha
Without the second action's detail guess, the list stays [#1 Login][#4 Typo] throughout. With the list's fetch moved into a createMemo that the derive reads (d => … list() …, refreshing the memo too), the bug does not occur.
Expected
[#1 Login][#4 Typo] at "rename #3 again" and "close accepted". The close's guess holds until its own refresh answers (D-35, and the rule that a guess holds until the server answers it). A guess on another store should not touch it.
Activity
perf(signals): the #4484 setter-entry clear runs only when the list i…
fix(signals): four optimistic store bugs (#4480, #4489, #4482, #4484) (…
ryansolid commentedon Oct 30, 2026
Fixed on
nextby #4511 (ecf3011); it ships in the next RC.Generated by Claude Code