Skip to content

[2.0 / next] createOptimisticStore: a removal guess re-based over a new question's answer shows the old list's rows next to the new ones #4482

Closed

Description

@ryansolid

Bug. A keyed createOptimisticStore over list(filter) can show rows from two different filters at once: the open list's rows next to the closed list's rows.

Found by a timing fuzzer over ReactiveScript apps, at next 5e68925. It is still present at next ab3d2ad, which has #4458. On an issue list, after a filter switch, the closed list showed open issues next to closed ones.

Repro (packages/web/test)

  1. Load the open list.
  2. Start two actions on row performance considerations with immutable data (using S.js variables instead of a state object) #3. One is a per-key guess (assign). The other is a removal guess (close).
  3. Switch the filter to closed. The switch holds behind the actions, which is correct.
  4. The closed answer lands. The list is still held.
  5. The assign is accepted and refreshes. The screen becomes the close guess's rows (JSXFragment / jsx-dom-expressions  #1, examples on codepen. #4, from the open list) plus the closed answer (Why ES6 Proxies? #2).
/**
 * @jsxImportSource @solidjs/web
 * @vitest-environment jsdom
 */
import { test } from "vitest";
import { For, action, createOptimisticStore, createSignal, 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("removal guess over a new question's answer", 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 setFilter!: (f: string) => void; let assignTo!: (id: number, who: string) => any; let closeOne!: (id: number) => any;
  render(() => {
    const [filter, sf] = createSignal("open"); setFilter = sf;
    const list = createMemo(() => ask(`issues:${filter()}`));
    const [issues, set] = createOptimisticStore(async (d: any[]) => { const r = list() as any[]; d.length = r.length; r.forEach((x, i) => { d[i] = x; }); }, [] as any[], { key: "id" });
    assignTo = action(function* (id: number, who: string) {
      const i = untrack(() => issues.findIndex((t: any) => t.id === id));
      set((d: any) => { d[i].assignee = who; });
      yield ask(`assign:${id}`);
      refresh(issues);
    });
    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} {it.assignee}]</i>}</For>;
  }, container);
  await tick();
  await settle("issues:open", [{ id: 1, assignee: null }, { id: 3, assignee: null }, { id: 4, assignee: "dana" }]); snap("loaded");
  assignTo(3, "guest"); await tick(); snap("assign #3");
  closeOne(3); await tick(); snap("close #3");
  setFilter("closed"); await tick(); snap("filter closed");
  await settle("issues:closed", [{ id: 2, assignee: "eve" }]); snap("closed answered");
  await settle("assign:3", {}); snap("assign accepted");
  await settle("close:3", {}); snap("close accepted");
  console.log(frames.join("\n"));
});

Output

loaded: [#1 ][#3 ][#4 dana]
assign #3: [#1 ][#3 guest][#4 dana]
close #3: [#1 ][#4 dana]
filter closed: [#1 ][#4 dana]
closed answered: [#1 ][#4 dana]
assign accepted: [#1 ][#4 dana][#2 eve]      <- open rows #1 and #4 beside closed #2
close accepted: [#2 eve]

Replacing the whole array in the removal guess (d.length = next.length; next.forEach(…)) instead of splicing gives the same output.

Things that make it go away:

  • Without the assign action, the switch holds until the close settles and then shows [#2 eve].
  • Without the assign's refresh(issues), the list stays held at [#1 ][#4 dana] until the close settles.

Expected

At "assign accepted", one of two things:

Either is fine. Rows from both questions at once is not.

It looks like the re-base (#4458, view.g) composes the removal from the rows the splice left, which are the open list's #1 and #4, laid over the new truth. It should compose the removal itself.

Related: #4441 (closed by #4458), #4440, #4480.

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