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: while an action holds the view, a handler reading a row the screen still shows throws the store's new error #4489
A keyed createOptimisticStore(async fn) whose fetch reads a filter signal. An action guesses a removal on it and waits. While it waits, the filter changes and the new fetch fails.
The view holds, which is correct: the effect keeps showing the guessed rows (#1 #4), and no error frame appears while the action is pending. But a handler (an untracked read) that reads one of the rows the screen is still showing throws the new fetch's error (declined).
So the user can see a row and click it, and the click handler crashes on it.id. The held view and the read disagree. Without the pending action, the error shows on screen and the read throws, so the two agree.
We found it in ReactiveScript's timing fuzzer, where clicking a row of the held list after a failed filter switch gave an uncaught StatusError. The throw is in the row's click handler.
Repro (vitest, packages/signals/tests)
import{it}from"vitest";import{action,createEffect,createOptimisticStore,createRoot,createSignal,flush,refresh,untrack}from"../src/index.js";consttick=async()=>{for(leti=0;i<4;i++){awaitnewPromise(r=>setTimeout(r,0));flush();}};it("a handler reads a row the screen still shows, after the held list failed",async()=>{constasks: {key: string;res: (v: any)=>void;rej: (e: any)=>void}[]=[];constask=(key: string)=>newPromise<any>((res,rej)=>asks.push({ key, res, rej }));constsettle=async(key: string,f: (a: any)=>void)=>{for(constaofasks.filter(a=>a.key===key)){asks.splice(asks.indexOf(a),1);f(a);}awaittick();};constframes: string[]=[];letsetFilter!: (f: string)=>void;letissues!: any[];letclose!: (id: number)=>any;createRoot(()=>{const[filter,sf]=createSignal("open");setFilter=sf;const[store,setStore]=createOptimisticStore(async()=>awaitask(`f:${filter()}`),[]asany[],{key: "id"}asany);issues=store;close=action(function*(id: number){setStore((d: any[])=>{consti=d.findIndex(x=>x.id===id);if(i>=0)d.splice(i,1);});yieldask(`close:${id}`);refresh(store);});createEffect(()=>(storeasany[]).map(i=>`#${i.id}`).join(" "),{effect: (v: string)=>{frames.push(v);},error: (e: any)=>{frames.push(`ERR ${e?.message??e}`);}}asany);});awaittick();awaitsettle("f:open",a=>a.res([{id: 1},{id: 3},{id: 4}]));close(3);awaittick();// action pending: guess removes #3setFilter("closed");flush();awaittick();// held by the actionawaitsettle("f:closed",a=>a.rej(newError("declined")));letread: string;try{read=String(untrack(()=>issues[1]?.id));}catch(e: any){read=`throws ${e?.message}`;}console.log(`frames: ${frames.join(" | ")}\nhandler reads row 2: ${read}`);});
While the view is held, a read outside it should see what the screen shows (4), the same way it would if the new fetch were still pending rather than failed. Or the error should reach the screen at the same time it reaches reads. Either way, what is shown and what is read should agree.
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