Skip to content

[2.0] A second optimistic guess made while the first action's refresh is in flight never shows through a keyed projection #4629

Closed

Description

@ryansolid

On next 8180332.

Setup:

  • A list comes from an async memo issues.
  • A createOptimistic log of pending assignments is overlaid on that list by a memo.
  • The result is shown through createProjection(..., { key: "id" }).
  • Each action guesses into the log, awaits the server, then calls refresh(issues).

Steps: action 1 is accepted, and its refresh(issues) is in flight. Action 2 now guesses into the same log.

Result: action 2's guess never shows. With the projection, it is not shown while action 2 is in flight. It is still not shown after action 1's re-ask lands. The row only changes when the server's own answer arrives. Without the projection (the overlay memo rendered directly), the guess is held instead: it shows only once action 1's re-ask lands.

A guess should show at once and hold until its own action is answered.

import { For, Loading, action, createMemo, createOptimistic, createProjection, createSignal, 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 proj of [false, true]) test(proj ? "projection" : "memo", 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) => { const i = asks.findIndex(a => a.key === key); f(asks.splice(i, 1)[0]); await tick(); };
  const c = document.body.appendChild(document.createElement("div"));
  let assign!: (id: number) => any;
  const server = [{ id: 3, a: "-" }, { id: 4, a: "dana" }];
  render(() => {
    const issues = createMemo(() => ask("issues"));
    const [truth] = createSignal<any[]>(() => []);
    const [assigning, guess] = createOptimistic<any[]>(() => truth());
    const overlay = createMemo(() => issues().map((i: any) => { const g = assigning().filter((x: any) => x.id === i.id).pop(); return g ? { ...i, a: g.a } : i; }));
    const shown = proj ? (() => { const p = createProjection(() => overlay() as any, [] as any, { key: "id" } as any); return () => p as any; })() : overlay;
    assign = action(function* (id: number) {
      guess(xs => [...xs, { id, a: "guest" }]);
      yield ask(`assign:${id}`);
      refresh(issues);
    });
    return <Loading fallback={<i>loading</i>}><For each={shown()}>{(it: any) => { const v = () => typeof it === "function" ? it() : it; return <i>[#{v().id} {v().a}]</i>; }}</For></Loading>;
  }, c);
  await tick();
  await settle("issues", a => a.res(server.map(x => ({ ...x }))));
  assign(3); await tick();
  server[0].a = "guest"; await settle("assign:3", a => a.res({}));   // action 1's refresh(issues) is now in flight
  assign(4); await tick();                                           // (4) expected [#4 guest]
  await settle("issues", a => a.res(server.map(x => ({ ...x }))));   // (5) action 1 done; expected [#4 guest]
  server[1].a = "guest"; await settle("assign:4", a => a.res({}));
  await settle("issues", a => a.res(server.map(x => ({ ...x }))));   // (6)
});

Frames, logged per case, labelled:

step memo projection
(4) action 2 out, action 1's re-ask in flight [#3 guest][#4 dana] [#3 guest][#4 dana]
(5) re-ask landed, action 2 still out [#3 guest][#4 guest] [#3 guest][#4 dana]
(6) all landed [#3 guest][#4 guest] [#3 guest][#4 guest]

Expected [#4 guest] from (4) on, in both cases.

Which case is a bug:

Where it came from: this shape is the overlay pattern that AI-written ReactiveScript apps use. It showed up as fuzzer holes in two separate apps in our eval round 10: "row assignee shows dana, server guest" when a user assigned two rows in quick succession.

Activity

  1. ryansolid commented on Nov 3, 2026

    @ryansolid
    MemberAuthor

    Fixed on next by #4633 (efd7f7d) and #4637 (885b99b); 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