Skip to content

[2.0 / next] createOptimisticStore: a row removed by an accepted action comes back when its refresh lands while another removal is pending #4441

Closed

Description

@ryansolid

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 next 5e68925 (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";
const tick = async () => { for (let i = 0; i < 4; i++) { await new Promise(r => setTimeout(r, 0)); flush(); } };
for (const second of [false, true])
test(`second close in the air: ${second}`, async () => {
  const asks: { key: string; res: (v: any) => void }[] = [];
  const ask = (key: string) => new Promise<any>(res => asks.push({ key, res }));
  const land = async (key: string, v: any) => { for (const a of asks.filter(a => a.key === key)) { asks.splice(asks.indexOf(a), 1); a.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 closeOne!: (id: number) => any;
  const dispose = render(() => {
    const [issues, set] = createOptimisticStore(async (d: any[]) => { const r = await ask("fetch"); d.length = r.length; r.forEach((x: any, i: number) => { d[i] = x; }); }, [] as any[], { key: "id" });
    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}]</i>}</For>;
  }, container);
  await tick();
  await land("fetch", [{ id: 1 }, { id: 3 }, { id: 4 }]); snap("loaded");
  closeOne(1); await tick(); snap("close 1");
  if (second) { closeOne(4); await tick(); snap("close 4"); }
  await land("close:1", {}); snap("close 1 accepted");
  await land("fetch", [{ id: 3 }, { id: 4 }]); snap("refresh answered");   // the server has closed #1, not yet #4
  console.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

  1. ryansolid commented on Oct 30, 2026

    @ryansolid
    MemberAuthor

    Fixed on next by #4458 (5f07d04); 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