Skip to content

[2.0 / next] createOptimisticStore: a pending removal guess is dropped from the screen when another action guesses on a different store #4484

Closed

Description

@ryansolid

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.

Related: #4441 (closed by #4458), #4482.

Activity

  1. added 2 commits that reference this issue on Oct 30, 2026
  2. 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