Skip to content

<Loading> inside a server component never reveals on document SSR — held-fragment policy and frames adoption deadlock (no claimant path) #2978

Description

@brenelz

Versions: solid-js / @solidjs/web 2.0.0-beta.31 at dc7b5c2, @dom-expressions/runtime 0.50.0-next.37, vite-plugin-solid 3.0.0-next.22, examples/playground turnkey setup (ssr: {}, serverFunctions: { components: true }). Related: #2977 (found while debugging the same playground), #2964 (whose fix introduced the held-swap policy this interacts with).

Repro

export const getServerComp = async (count: number) => {
  "use server";
  return () => {
    const test = createMemo(async () => {
      await new Promise(r => setTimeout(r, 1000));
      return Math.random();
    });
    return (
      <div>
        <h1>Playground2</h1>
        <Loading fallback={<div>loading new value…</div>}>
          {test()}
        </Loading>
      </div>
    );
  };
};

export default function App() {
  const [count, setCount] = createSignal(0);
  const ServerComp = dynamic(() => getServerComp(count()));
  return (
    <Loading fallback={<div>Loading...</div>}>
      <ServerComp />
      <button onClick={() => setCount(count() + 1)}>Increment</button>
    </Loading>
  );
}

Load the page. The shell streams in with loading new value…, and it never reveals — the fallback stays on screen forever. The client-slot machinery is not involved (plain {test()} reproduces it).

What the wire and DOM show

The server does everything right. The document stream completes at ~1s and contains the placeholder (<template id="pl-11"> + fallback), the content template (<template id="11"> with the resolved markup), and the reveal call $df("11").

Client-side after load (all verified in-page):

  • placeholder and content template both still in the DOM
  • _$HY.done === true, _$HY.fr.pending() === true
  • _$HY.f("11") returns 0 — the swap was held
  • manually calling $dfr("11") swaps the content in correctly

Root cause: two seams each waiting for the other

  1. The held-swap policy (hydration.ts fragmentPolicy, introduced by the Server component that settles after the shell flush never mounts (blank region, no fallback, no error) #2964 fix): a $df arriving after global hydration completes is held until a claimant registers via claimFragment — which is only ever called by a client-hydrated <Loading> boundary (hydration.ts:1844).
  2. The frames document adoption (adoptBoundary, solid-web/frames/src/client.ts) anticipates held fragments: its recordsPending() seam answers "may the document still deliver records?" with _$HY.fr.pending(), and a recordless occurrence defers, re-checking until that flips false (the comment at the seam even notes "a held fragment's replay can still deliver one").
  3. Nothing on the adoption side ever claims_$HY.fr only exposes { pending, subscribe }; there is no claim for an integration to call. So the ledger holds the swap waiting for a claimant, the template/placeholder stay in the DOM, fragmentPending() stays true, the adoption's occurrence defers forever. Deadlock; the fallback wedges.

The timing window explains why ordinary streamed boundaries are fine: an inline $df executing during parse (before the module that installs _$HY.f loads) swaps via $dfr directly. The wedge needs the fragment to settle after client boot — which any real data fetch does.

The policy is right to hold — the claimant is what's missing

Force-revealing the held fragment with an unclaimed $dfr("11") swaps the markup in but orphans the region permanently: subsequent refetches POST, the server renders and responds, and the response is never applied. That is exactly the #2964 inert-nodes scenario the policy exists to prevent — so the fix is not to weaken the hold.

Meanwhile the frame-stream path is fully correct: clicking Increment on a wedged page recovers it — the refetch response (start → html → slot → fragment → reveal → complete) applies cleanly and removes the stale placeholder. Only the document-SSR reveal path has no claimant.

Secondary defect

_$HY.fr.pending() stays true for the rest of the page's life even after a refetch replaces the wedged region — the document fragment's ledger entry never resolves. Anything consulting boundaryMayArrive() / recordsPending() keeps reading "the document may still deliver" indefinitely.

Suggested direction

Expose claim(id) on _$HY.fr alongside pending/subscribe, and have adoptBoundary (or the frame's occurrence classification, which already knows claimScope: id and the adopted subtree) claim fragments whose pl-* placeholders sit inside the markup it adopts. The claim replays the held swap before the occurrence re-checks, and the existing installRevealHook subscription already re-indexes on reveal.

Dev-tooling footnote (separate, vite-plugin-solid): during debugging, vite dev repeatedly served a stale SSR copy of the "use server" module across edits until a full server restart — the stream contained markup already deleted from the source. Worth its own look if others hit "my server component won't update".

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