Skip to content

[2.0] Filter change never commits when a second async memo reads the optimistically guessed list #4577

Closed

Description

@ryansolid

Shape. This is the same scenario as #4576, with one difference: the second async memo, other, reads the guessed list itself (issues.length before it asks; the readsList variant in the probe below). Every ask answers normally, in order: issues:closed, other:closed, then the action's close:1, then the refresh.

Expected. After the drain the screen settles on closed: [#2] | O-closed. Under F-2 a held change commits once the async it needs has answered. Under D-9, other and the list share an on-screen async derivation and settle as one. Under D-33/F-11, the guess lives only as long as the action's hold.

Actual. Every park readsList … variant (shared/split × inline/memo) ends stuck at open: [#3][#4] | O-open after the drain answers every remaining ask. The filter change never lands: the screen keeps the old filter, and the action's removal is applied to the old list. The behaviour is the same on next and with #4564 applied.

Found during review of #4564.

Probe (packages/signals/tests, run one variant at a time, e.g. -t "park readsList shared memo"; an unhandled rejection pollutes later tests in the file)
import { test } from "vitest";
import { action, createErrorBoundary, createMemo, createOptimisticStore, createRenderEffect, createRoot, createSignal, flush, refresh } from "../src/index.js";
const tick = async () => { for (let i = 0; i < 4; i++) { await new Promise(r => setTimeout(r, 0)); flush(); } };
for (const live of ["normal", "never", "reject", "readsList"] as const)
for (const shape of ["shared", "split"] as const)
for (const mode of ["inline", "memo"] as const)
test(`park ${live} ${shape} ${mode}`, async () => {
  const asks: { key: string; res: (v: any) => void }[] = [];
  const ask = (key: string) => new Promise<any>((res, rej) => asks.push({ key, res, rej } as any));
  const settle = async (key: string, v: any, rej = false) => { const i = asks.findIndex(a => a.key === key); if (i < 0) { frames.push(`!noask ${key}`); return; } const a = asks.splice(i, 1)[0]; rej ? (a as any).rej(v) : a.res(v); await tick(); };
  const frames: string[] = [];
  let f = "", l = "", o = "";
  let mq = false; const snap = () => { if (!mq) { mq = true; queueMicrotask(() => { mq = false; frames.push("FRAME " + `${f}: ${l} | ${o}`); }); } };
  let setFilter!: (s: string) => void, closeOne!: (id: number) => any;
  createRoot(() => {
    const [filter, sf] = createSignal("open"); setFilter = sf;
    const [issues, set] = createOptimisticStore<any[]>(() => ask(`issues:${filter()}`), [], { key: "id" });
    const other = createMemo(() => live === "readsList" ? (issues.length, ask(`other:${filter()}`)) : ask(`other:${filter()}`));
    closeOne = action(function* (id: number) { set(list => { const i = list.findIndex(t => t.id === id); if (i >= 0) list.splice(i, 1); }); yield ask(`close:${id}`); refresh(issues); });
    const list = () => issues.map((r: any) => `[#${r.id}]`).join("");
    if (shape === "shared") {
      const s = () => `${filter()}: ${list()}`;
      createRenderEffect(mode === "memo" ? createMemo(s) : s, v => { const [a, b] = v.split(": "); f = a; l = b; snap(); });
    } else {
      createRenderEffect(filter, v => { f = v; snap(); });
      createRenderEffect(mode === "memo" ? createMemo(list) : list, v => { l = v; snap(); });
    }
    createRenderEffect(createErrorBoundary(() => other(), () => "ERR") as any, v => { o = v; snap(); });
  });
  await tick();
  await settle("issues:open", [{ id: 1 }, { id: 3 }, { id: 4 }]); await settle("other:open", "O-open");
  frames.push("--setFilter"); setFilter("closed"); await tick();
  frames.push("--guess"); closeOne(1); await tick();
  frames.push("--issuesClosed"); await settle("issues:closed", [{ id: 1 }, { id: 2 }]);
  if (live === "normal" || live === "readsList") { frames.push("--otherClosed"); await settle("other:closed", "O-closed"); }
  if (live === "reject") { frames.push("--otherReject"); await settle("other:closed", new Error("X"), true); }
  frames.push("--closeDone"); await settle("close:1", {});
  frames.push("--refresh"); await settle("issues:closed", [{ id: 2 }]);
  if (live === "never") { frames.push("--lateOther"); await settle("other:closed", "O-closed"); }
  for (let k = 0; k < 10 && asks.length; k++) { const a = asks.shift()!; frames.push("drain " + a.key); a.key.startsWith("other") ? a.res("O-" + a.key.split(":")[1]) : a.key.startsWith("issues:closed") ? a.res([{ id: 2 }]) : a.key.startsWith("issues:open") ? a.res([{ id: 3 }, { id: 4 }]) : a.res({}); await tick(); }
  for (let i = 0; i < 5; i++) await tick();
  frames.push(`final=${f}: ${l} | ${o}`);
  frames.push("asks:" + asks.map(a => a.key).join(","));
  process.stderr.write(`PARK ${shape} ${mode} ${JSON.stringify(frames)}\n`);
});

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