Skip to content

[2.0 / next] A render effect in a re-armed Loading fallback holds the content's reveal (S-22 row 3′) #4268

Closed

Description

@ryansolid

Reproduced on next at 2a0da2f (2026-10-24), with @solidjs/signals, solid-js and @solidjs/universal built from source. Run: node --conditions=browser --conditions=development <script>.

Rule. SCENARIOS.md S-22 row 3′ (D-18): "if content(2) lands before
meta(2) → content(2) — the fallback is never seen."
Pinned by
tests/loading-on-rearm-reveal-3540.test.ts "content lands before the
fallback: the fallback is never seen".

Shape. An outer Loading, inside it a Loading with on: id whose
fallback reads async meta and whose content reads async content; both
memos depend on id. After the first load, setId(2); content(2) lands
before meta(2).

Observed. If the fallback creates a render effect that reads meta —
which is what any compiled JSX fallback does: <p>Meta {meta()}</p> is an
element plus an insert effect — the screen keeps content(1) after
content(2) lands, and reveals content(2) only when meta(2) lands:

                     effect-in-fallback    read-in-fallback (the pinned shape)
setId(2)             "content c1"          "content c1"
content(2) lands     "content c1"   ✗      "content c2"   ✓
meta(2) lands        "content c2"          "content c2"

dv3-web.mjs shows the same through solid-js's <Loading> and
@solidjs/universal: an element fallback fails, an array fallback
(["Meta ", () => meta()]) and a static fallback pass.

Likely cause. The fallback's render effect is a frame reader that is
pending on meta(2) and counts toward the change's hold, though the
fallback is never the display in this race (a reader behind content
that is not shown should hold nothing). The pinned test reads fallbackData()
directly in the fallback function, so no render effect exists and the
case is not covered.

Repro

dv3-signals.mjs (effect-in-fallback | read-in-fallback)

// signals only: a re-armed boundary whose fallback reads pending data; the content lands first.
// Expected (SCENARIOS.md S-22 row 3′, loading-on-rearm-reveal-3540 "content lands before the fallback"): content(2), fallback never seen.
import { createRoot, createSignal, createMemo, createRenderEffect, createLoadingBoundary, flush, untrack } from "@solidjs/signals";

const flights = new Map();
const fetchKey = key => { if (!flights.has(key)) { let resolve; const p = new Promise(r => (resolve = r)); flights.set(key, { p, resolve }); } return flights.get(key).p; };
const land = async (key, v) => { flights.get(key).resolve(v); await new Promise(r => setTimeout(r, 0)); flush(); await new Promise(r => setTimeout(r, 0)); flush(); };

let screen = "";
const [id, setId] = createSignal(1);
createRoot(() => {
  const meta = createMemo(() => fetchKey("m:" + id()));
  const content = createMemo(() => fetchKey("c:" + id()));
  const variant = process.argv[2] ?? "effect-in-fallback";
  const fallback = variant === "read-in-fallback"
    ? () => `Meta ${meta()}`                                                   // the shape Solid's pinned test uses: passes
    : () => { let t = "Meta"; createRenderEffect(() => meta(), v => { t = `Meta ${v}`; }); return t; };   // a render effect inside the fallback (what an element + insert is)
  const inner = untrack(() => createLoadingBoundary(() => `content ${content()}`, fallback, { on: id }));
  const outer = untrack(() => createLoadingBoundary(() => inner(), () => "outer"));
  createRenderEffect(() => outer(), v => { screen = v; });
});
flush();
const show = label => console.log(label.padEnd(26), JSON.stringify(screen));
show("mount");
await land("m:1", 1); show("meta(1) lands");
await land("c:1", "c1"); show("content(1) lands");
setId(2); flush(); await new Promise(r => setTimeout(r, 0)); flush(); show("setId(2)");
await land("c:2", "c2"); show("content(2) lands FIRST");      // expected: "content c2"
await land("m:2", 2); show("meta(2) lands");

dv3-web.mjs (element-fallback | array-fallback | static-fallback)

// through solid-js's <Loading> and a renderer: a re-armed boundary whose fallback reads pending data; the content lands first.
// Expected (SCENARIOS.md S-22 row 3′): after content(2) lands, the screen shows c2 and the fallback is never seen.
import { createRenderer } from "@solidjs/universal";
import { Loading } from "solid-js";
import { createSignal, createMemo, flush } from "@solidjs/signals";

// a string-tree renderer
const R = createRenderer({
  createElement: tag => ({ tag, children: [] }), createTextNode: text => ({ text }),
  replaceText: (n, v) => { n.text = v; }, isTextNode: n => "text" in n, setProperty() {},
  insertNode: (p, n, a) => { if (n.parent) n.parent.children.splice(n.parent.children.indexOf(n), 1); const i = a ? p.children.indexOf(a) : -1; i < 0 ? p.children.push(n) : p.children.splice(i, 0, n); n.parent = p; },
  removeNode: (p, n) => { const i = p.children.indexOf(n); if (i >= 0) p.children.splice(i, 1); n.parent = undefined; },
  getParentNode: n => n.parent, getFirstChild: n => n.children?.[0], getNextSibling: n => n.parent?.children[n.parent.children.indexOf(n) + 1],
});
const text = n => "text" in n ? n.text : n.children.map(text).join("");

const flights = new Map();
const fetchKey = key => { if (!flights.has(key)) { let resolve; const p = new Promise(r => (resolve = r)); flights.set(key, { p, resolve }); } return flights.get(key).p; };
const tick = async () => { await new Promise(r => setTimeout(r, 0)); flush(); await new Promise(r => setTimeout(r, 0)); flush(); };
const land = async (key, v) => { flights.get(key).resolve(v); await tick(); };

const [id, setId] = createSignal(1);
const root = { tag: "root", children: [] };
const variant = process.argv[2] ?? "element-fallback";
R.render(() => {
  const meta = createMemo(() => fetchKey("m:" + id()));
  const content = createMemo(() => fetchKey("c:" + id()));
  const inner = () => R.createComponent(Loading, {
    get on() { return id(); },
    get fallback() {
      if (variant === "static-fallback") return R.createTextNode("Meta");
      if (variant === "array-fallback") return [R.createTextNode("Meta "), () => String(meta())];
      const p = R.createElement("p"); R.insert(p, [R.createTextNode("Meta "), () => String(meta())]); return p;   // what <p>Meta {meta()}</p> compiles to
    },
    get children() { return () => String(content()); },
  });
  return R.createComponent(Loading, { get fallback() { return R.createTextNode("outer"); }, get children() { return inner(); } });
}, root);
flush();
const show = label => console.log(label.padEnd(26), JSON.stringify(text(root)));
show("mount"); await tick(); show("mount (settled)");
await land("m:1", 1); show("meta(1) lands");
await land("c:1", "c1"); show("content(1) lands");
setId(2); flush(); await tick(); show("setId(2)");
await land("c:2", "c2"); show("content(2) lands FIRST");     // expected: "c2"
if (flights.has("m:2")) { await land("m:2", 2); show("meta(2) lands"); }

Found by ReactiveScript's conformance suite, which diffs its spec against Solid's SCENARIOS.md.

Activity

  1. ryansolid commented on Oct 26, 2026

    @ryansolid
    MemberAuthor

    Fixed on next by #4282 (3b6f839); 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