You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[2.0 / next] createOptimisticStore: a row removed by an accepted action comes back when its refresh lands while another removal is pending #4441
Two actions each remove a row from a keyed createOptimisticStore with splice. The first is accepted and refreshes; when that refresh answers (without the row) while the second action is still pending, the list shows the removed row again, at the position of the row the second action removed. The server, both guesses and the refresh's answer all say it is gone.
Found at next5e68925 (2.0.0-rc.20) by a timing fuzzer over ReactiveScript apps (issue list, close two issues).
Repro (packages/web/test)
/** * @jsxImportSource@solidjs/web * @vitest-environment jsdom */import{test}from"vitest";import{For,action,createOptimisticStore,flush,refresh}from"solid-js";import{render}from"../src/index.js";consttick=async()=>{for(leti=0;i<4;i++){awaitnewPromise(r=>setTimeout(r,0));flush();}};for(constsecondof[false,true])test(`second close in the air: ${second}`,async()=>{constasks: {key: string;res: (v: any)=>void}[]=[];constask=(key: string)=>newPromise<any>(res=>asks.push({ key, res }));constland=async(key: string,v: any)=>{for(constaofasks.filter(a=>a.key===key)){asks.splice(asks.indexOf(a),1);a.res(v);}awaittick();};constcontainer=document.body.appendChild(document.createElement("div"));constframes: string[]=[];constsnap=(l: string)=>frames.push(`${l}: ${container.textContent}`);letcloseOne!: (id: number)=>any;constdispose=render(()=>{const[issues,set]=createOptimisticStore(async(d: any[])=>{constr=awaitask("fetch");d.length=r.length;r.forEach((x: any,i: number)=>{d[i]=x;});},[]asany[],{key: "id"});closeOne=action(function*(id: number){set((d: any)=>{consti=d.findIndex((t: any)=>t.id===id);if(i>=0)d.splice(i,1);});yieldask(`close:${id}`);refresh(issues);});return<Foreach={issues}>{(it: any)=><i>[#{it.id}]</i>}</For>;},container);awaittick();awaitland("fetch",[{id: 1},{id: 3},{id: 4}]);snap("loaded");closeOne(1);awaittick();snap("close 1");if(second){closeOne(4);awaittick();snap("close 4");}awaitland("close:1",{});snap("close 1 accepted");awaitland("fetch",[{id: 3},{id: 4}]);snap("refresh answered");// the server has closed #1, not yet #4console.log(`second close in the air: ${second}\n `+frames.join("\n "));dispose();container.remove();});
Output
second close in the air: false
loaded: [#1][#3][#4]
close 1: [#3][#4]
close 1 accepted: [#3][#4]
refresh answered: [#3][#4]
second close in the air: true
loaded: [#1][#3][#4]
close 1: [#3][#4]
close 4: [#3]
close 1 accepted: [#3]
refresh answered: [#3][#1] <- #1 is back, in #4's place
The same happens with a <Loading> around the list, and when the guess replaces the whole array (d.length = next.length; next.forEach((x, i) => d[i] = x)) instead of splicing.
Expected
refresh answered: [#3]: the answer without #1, with #4's removal still guessed.
Related: #4440 (same store, a row renders empty after a list guess joins a pending refresh), #4329 (closed).
Activity
ryansolid commentedon Oct 30, 2026
Fixed on
nextby #4458 (5f07d04); it ships in the next RC.Generated by Claude Code
fix(signals): re-base an arrangement guess from the rows it wrote (#4441