Skip to content

[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

Closed

Description

@ryansolid

On next (ab3d2ad), packages/signals.

What happens

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";
const tick = async () => { for (let i = 0; i < 4; i++) { await new Promise(r => setTimeout(r, 0)); flush(); } };
it("a handler reads a row the screen still shows, after the held list failed", 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) => { for (const a of asks.filter(a => a.key === key)) { asks.splice(asks.indexOf(a), 1); f(a); } await tick(); };
  const frames: string[] = []; let setFilter!: (f: string) => void; let issues!: any[]; let close!: (id: number) => any;
  createRoot(() => {
    const [filter, sf] = createSignal("open"); setFilter = sf;
    const [store, setStore] = createOptimisticStore(async () => await ask(`f:${filter()}`), [] as any[], { key: "id" } as any);
    issues = store;
    close = action(function* (id: number) {
      setStore((d: any[]) => { const i = d.findIndex(x => x.id === id); if (i >= 0) d.splice(i, 1); });
      yield ask(`close:${id}`);
      refresh(store);
    });
    createEffect(() => (store as any[]).map(i => `#${i.id}`).join(" "), { effect: (v: string) => { frames.push(v); }, error: (e: any) => { frames.push(`ERR ${e?.message ?? e}`); } } as any);
  });
  await tick();
  await settle("f:open", a => a.res([{ id: 1 }, { id: 3 }, { id: 4 }]));
  close(3); await tick();                       // action pending: guess removes #3
  setFilter("closed"); flush(); await tick();   // held by the action
  await settle("f:closed", a => a.rej(new Error("declined")));
  let read: 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}`);
});

Output:

frames: #1 #3 #4 | #1 #4
handler reads row 2: throws declined

Without close(3):

frames: #1 #3 #4 | ERR declined
handler reads row 2: throws declined

Expected

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

  1. ryansolid commented on Oct 30, 2026

    @ryansolid
    MemberAuthor

    Fixed on next by #4511 (ecf3011); it ships in the next RC.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions