Repository navigation
Description
Bug. A keyed createOptimisticStore over list(filter) can show rows from two different filters at once: the open list's rows next to the closed list's rows.
Found by a timing fuzzer over ReactiveScript apps, at next 5e68925. It is still present at next ab3d2ad, which has #4458. On an issue list, after a filter switch, the closed list showed open issues next to closed ones.
Repro (packages/web/test)
- Load the open list.
- Start two actions on row performance considerations with immutable data (using S.js variables instead of a state object) #3. One is a per-key guess (assign). The other is a removal guess (close).
- Switch the filter to closed. The switch holds behind the actions, which is correct.
- The closed answer lands. The list is still held.
- The assign is accepted and refreshes. The screen becomes the close guess's rows (JSXFragment / jsx-dom-expressions #1, examples on codepen. #4, from the open list) plus the closed answer (Why ES6 Proxies? #2).
/**
* @jsxImportSource @solidjs/web
* @vitest-environment jsdom
*/
import { test } from "vitest";
import { For, action, createOptimisticStore, createSignal, 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("removal guess over a new question's answer", 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 setFilter!: (f: string) => void; let assignTo!: (id: number, who: string) => any; let closeOne!: (id: number) => any;
render(() => {
const [filter, sf] = createSignal("open"); setFilter = sf;
const list = createMemo(() => ask(`issues:${filter()}`));
const [issues, set] = createOptimisticStore(async (d: any[]) => { const r = list() as any[]; d.length = r.length; r.forEach((x, i) => { d[i] = x; }); }, [] as any[], { key: "id" });
assignTo = action(function* (id: number, who: string) {
const i = untrack(() => issues.findIndex((t: any) => t.id === id));
set((d: any) => { d[i].assignee = who; });
yield ask(`assign:${id}`);
refresh(issues);
});
closeOne = action(function* (id: number) {
set((d: any) => { const i = d.findIndex((t: any) => t.id === id); if (i >= 0) d.splice(i, 1); });
yield ask(`close:${id}`);
refresh(issues);
});
return <For each={issues}>{(it: any) => <i>[#{it.id} {it.assignee}]</i>}</For>;
}, container);
await tick();
await settle("issues:open", [{ id: 1, assignee: null }, { id: 3, assignee: null }, { id: 4, assignee: "dana" }]); snap("loaded");
assignTo(3, "guest"); await tick(); snap("assign #3");
closeOne(3); await tick(); snap("close #3");
setFilter("closed"); await tick(); snap("filter closed");
await settle("issues:closed", [{ id: 2, assignee: "eve" }]); snap("closed answered");
await settle("assign:3", {}); snap("assign accepted");
await settle("close:3", {}); snap("close accepted");
console.log(frames.join("\n"));
});Output
loaded: [#1 ][#3 ][#4 dana]
assign #3: [#1 ][#3 guest][#4 dana]
close #3: [#1 ][#4 dana]
filter closed: [#1 ][#4 dana]
closed answered: [#1 ][#4 dana]
assign accepted: [#1 ][#4 dana][#2 eve] <- open rows #1 and #4 beside closed #2
close accepted: [#2 eve]
Replacing the whole array in the removal guess (d.length = next.length; next.forEach(…)) instead of splicing gives the same output.
Things that make it go away:
- Without the assign action, the switch holds until the close settles and then shows
[#2 eve]. - Without the assign's
refresh(issues), the list stays held at[#1 ][#4 dana]until the close settles.
Expected
At "assign accepted", one of two things:
- the held frame,
[#1 ][#4 dana], because the close action that the switch joined is still pending; or - the close guess composed over the closed answer,
[#2 eve]. The guess removes performance considerations with immutable data (using S.js variables instead of a state object) #3, which is not in the answer.
Either is fine. Rows from both questions at once is not.
It looks like the re-base (#4458, view.g) composes the removal from the rows the splice left, which are the open list's #1 and #4, laid over the new truth. It should compose the removal itself.
Activity
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
fix(signals): a guess written while a new question is in flight re-ba…