Skip to content

[2.0 / next] A first-load <Loading> reveals with isPending() === true when the memo only holds an answer to an older question (F-9) #4569

Closed

Description

@brenelz

Describe the bug

a2 awaits a0, and a0 depends on a signal s. On the first load under <Loading>, a0:0 lands and a2 asks a2:11. Then s changes, so a0 asks a0:1. When a2:11 lands, a2 holds an answer to the old question while the current one is still in flight. At that point the boundary reveals its content with isPending(() => a2()) reading true, and keeps showing pending until a2:20 lands.

a2 has never committed, so per F-9 it has no verdict: "a first load is loading, not pending", and isPending "cannot be true on creation". Inside an owner, isPending of such a node should do what the read does and hold the boundary until the answer to the current question lands.

Found by diffing generated programs from ryansolid/reactivescript against Solid.

Your Example Website or App

The vitest spec below (in packages/web/test/), on next @ 62745f6.

Steps to Reproduce the Bug or Issue

import { render } from "@solidjs/web";
import { createMemo, createSignal, flush, isPending, Loading } from "solid-js";
import { expect, it } from "vitest";

const tick = async () => { for (let i = 0; i < 2; i++) { await new Promise(r => setTimeout(r, 0)); flush(); } };

it("a first load does not reveal with isPending() === true", async () => {
  const flights = new Map<string, (v: number) => void>();
  const ask = (key: string) => new Promise<number>(resolve => flights.set(key, resolve));
  const [s, setS] = createSignal(0);
  const el = document.createElement("div");
  const frames: string[] = [];
  const dispose = render(() => {
    const a0 = createMemo(() => ask("a0:" + s()));
    const a2 = createMemo(async () => ask("a2:" + (await a0())));
    return (
      <Loading fallback={<p>loading</p>}>
        <b>{isPending(() => a2()) ? "pending" : "settled"}</b>
      </Loading>
    );
  }, el);
  await tick();                         frames.push(el.textContent!);
  flights.get("a0:0")!(11); await tick(); frames.push(el.textContent!);   // asks a2:11
  setS(1); flush(); await tick();       frames.push(el.textContent!);   // asks a0:1
  flights.get("a2:11")!(16); await tick(); frames.push(el.textContent!);  // an answer to the old question
  flights.get("a0:1")!(20); await tick(); frames.push(el.textContent!);   // asks a2:20
  flights.get("a2:20")!(21); await tick(); frames.push(el.textContent!);
  dispose();
  expect(frames).toEqual(["loading", "loading", "loading", "loading", "loading", "settled"]);
});

npx vitest run in packages/web. Received frames: loading, loading, loading, pending, pending, settled.

Expected behavior

Frames loading, loading, loading, loading, loading, settled: the boundary first reveals when a2 answers the current question, and its first frame shows no pending verdict.

Platform

  • OS: macOS
  • Browser: n/a (jsdom)
  • Version: solid-js 2.0.0-rc.20, next @ 62745f6

Activity

  1. ryansolid commented on Nov 1, 2026

    @ryansolid
    Member

    Thanks for this one — it's a genuine edge the differential found, and it needed a ruling rather than a fix.

    Ruling (2026-10-31): today's frames stand. A node's question changes only when the node itself re-runs (#3373: "nothing superseded it"). setS(1) re-asks a0 and marks a2 pending by propagation — that changes a0's status, not a2's inputs — so when a2:11 lands it is the current answer to a2's current question. It initializes a2, which is then committed and pending on a0's flight. A <Loading> whose content reads only isPending(() => a2()) reveals when the verdict exists (S-40: a fresh boundary reading only a display-ahead channel shows what it has), so the first frame is pending. F-9's "cannot be true on creation" is about the node at creation, not a boundary's first frame; #4459 already pins a true first reveal for a node answered-then-re-asked.

    The crux, as ruled: does an upstream re-ask that marks a node pending by propagation change the node's question before the node re-runs? No. The alternative would need a "supersession by propagation" no rule has, a new resting state (staged, uninitialized, pending, unheld), would treat an uninitialized node more strictly than a committed one (#3373 writes the same landing to a committed node), and would contradict #4459/#4495's first reveals.

    Control worth knowing: any plain read of a2 beside the probe — {a2()} alone, or D-23's canonical <p>{a2()}</p><span>{isPending(a2) …}</span> — already gives the frames you expected (loading ×5, settled): a reader of the node itself waits on it (F-2, no screen to keep). Only verdict-only content reveals early, and that asymmetry is the rule.

    Pinned with the ruling, the control, a 3-deep chain and the no-boundary form, plus D-23/F-9/S-40 doc clauses, in #4571 — this issue will close with that PR.

    — Claude via Cursor

  2. added a commit that references this issue on Nov 1, 2026
  3. ryansolid commented on Nov 1, 2026

    @ryansolid
    Member

    Ruled and pinned on next by #4571 (77931d6): a first load answered while pending by propagation is committed and pending (D-23).


    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