Skip to content

[2.0 / next] A Loading fallback that returns a function reading latest(x) rebuilds without end under a mount that joined a hold #4467

Closed

Description

@ryansolid

Found while reviewing #4464 and #4465. It reproduces on next @ 131ceb8, and neither PR changes it.

Setup

  • An action holds a write to x.
  • A memo reads x, so it joins the hold and is born held. That memo mounts a createLoadingBoundary and reads it.
  • The fallback returns a function, and the mount calls that function in its own pass.
  • The content is async and lands at 50 ms, before the action finishes at 200 ms.

What happens, by what the fallback returns:

fallback returns result
a string from latest(x) fine: the screen settles on ["1:C"] and the fallback builds once
a string through a memo of latest(x) fine, same as above
a function whose body reads latest(x) the fallback is rebuilt without end, until "Potential Infinite Loop Detected" halts the reactive system
a function that reads a memo of latest(x) the fallback built the fallback is rebuilt without end, as with the function above (corrected: an earlier version of this issue said the screen sticks at 0:F0)

The loop is wider than the fallback forms above. Plain inline latest(x) and isPending(x) in the mount's own pass, and the split-memo forms of both, loop the same way on next (found in #4471's review). #4471 settles all of them.

Expected: every form builds the fallback once and settles on 1:C once the content lands. Grouping must not change the result (#4284), and calling the fallback's value is still a read of it.

Repro (place under packages/signals/tests/):

import { afterEach, beforeEach, expect, test, vi } from "vitest";
import { action, createLoadingBoundary, createMemo, createRenderEffect, createRoot, createSignal, flush, latest, untrack } from "../src/index.js";

beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
const sleep = (ms: number) => new Promise<void>(r => setTimeout(r, ms));

for (const v of ["direct", "innerMemo", "fnDirect", "fnMemo"] as const)
  test(`fallback ${v} under a mount that joined the hold settles on 1:C`, async () => {
    const [x, setX] = createSignal(0);
    createRoot(() => createRenderEffect(x, () => {}));
    flush();
    action(function* () { setX(1); yield sleep(200); })();
    flush();
    let builds = 0;
    const screens: unknown[] = [];
    createRoot(() => {
      const content = createMemo(async () => { await sleep(50); return "C"; });
      const mount = createMemo(() => {
        const v0 = x();
        const b = untrack(() =>
          createLoadingBoundary(() => content(), () => {
            builds++;
            if (builds > 50) throw new Error("runaway fallback rebuild");
            if (v === "direct") return `F${latest(x)}`;
            if (v === "fnDirect") return () => `F${latest(x)}`;
            const t = createMemo(() => latest(x));
            if (v === "fnMemo") return () => `F${t()}`;
            return `F${t()}`;
          })
        );
        const r: any = b();
        return `${v0}:${typeof r === "function" ? r() : r}`;
      });
      createRenderEffect(mount, s => void screens.push(s));
    });
    flush();
    await vi.advanceTimersByTimeAsync(100);
    flush();
    await vi.advanceTimersByTimeAsync(200);
    flush();
    expect(builds).toBe(1);
    expect(screens).toEqual(["1:C"]);
  });

Likely cause (inferred, not confirmed): the function's latest(x) runs in the mount's pass, not the fallback's. That verdict read re-dirties the boundary output while the hold is up, so the output rebuilds the fallback and the cycle repeats.


Generated by Claude Code

Activity

  1. ryansolid commented on Oct 30, 2026

    @ryansolid
    MemberAuthor

    Fixed on next by #4471 (663ecb0); 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