Skip to content

Streamed <Loading> boundary silently dies when the trailing _fr resolution chunk is lost (host truncates stream) — plus _$HY.fe is never implemented #2958

Closed

Description

@brenelz

Summary

When a streamed <Loading> boundary's <id>_fr resolution is dropped because the response stream closes first, the boundary never resumes. It keeps rendering fallback() as detached client DOM while the server's streamed content stays in the document, unclaimed and inert. Every binding inside the boundary is then dead after hydration — including plain signals with no async involvement.

The visible symptom is nasty because nothing errors: the page looks correct (the server's markup is right there), the reactive graph is provably alive (effects re-run with fresh values), but no update ever paints inside the boundary.

Reproduction

Solid 2.0.0-beta.28 + @solidjs/web 2.0.0-beta.28, streamed SSR (renderRouterToStream via @tanstack/solid-router 2.0.0-beta.28, Vite 8.1.5, workerd/Cloudflare — also reproduces in dev and in vite preview).

function CounterTest() {
  const [count, setCount] = createSignal(0)
  const message = createMemo(() => slowServerFn())   // ~1s, suspends the boundary

  return (
    <section>
      <p>A (outside boundary): <span class="probe-a">{count()}</span></p>
      <Loading fallback={<p>Loading...</p>}>
        <p>B (inside boundary): <span class="probe-b">{count()}</span></p>
        <p>C (async read): <span class="probe-c">{message()}</span></p>
      </Loading>
      <button onClick={() => setCount(count() + 1)}>bump</button>
    </section>
  )
}

Click bump once after hydration:

probe before after
A — plain signal, outside <Loading> 0 1 ✅
B — same plain signal, inside <Loading> 0 0 ❌
C — async read, inside <Loading> value unchanged ❌

B shares its signal with A. The only difference is that it lives inside the boundary.

Root cause

  1. registerFragment (in @dom-expressions/runtime, bundled into @solidjs/web/dist/server.js) resolves the boundary's <id>_fr promise behind two microtask hops:

    registry.set(key, {
      resolve: err => queue(() => queue(() => {
        err ? reject(err) : resolve(true);
        queue(flushEnd);
      }))
    });
    serializer.write(key + "_fr", p);
  2. If the response stream closes before those hops run, the resolution is never serialized. The client receives the $df() swap for the fragment, but no $R[n](...) call for <id>_fr. Live registry state on the stuck page:

    _$HY.r["…1110"]      // the boundary's serialized data → { s: 1, v: "Hello World" } ✅
    _$HY.r["…1113_fr"]   // the fragment resolver        → no `.s` at all, still pending ❌
  3. hydratedCreateLoadingBoundary takes its waitAndResume(fr, resume, assetPromise, false) path and waits on that promise forever — resumeBoundaryHydration never runs. The boundary returns fallback(), which becomes the live client tree; the server's streamed content is never claimed. Dev mode reports it:

    Hydration completed with 2 unclaimed server-rendered node(s):
      <section _hk="…381111" class="page">
      <p _hk="…3811130" class="loading">
    
  4. Resolving that one promise by hand in the console makes everything work immediately — resume fires, the content is claimed, and B starts updating:

    const r = self.$R[1]; r.s(true); r.p.s = 1; r.p.v = true;

So the client-side boundary logic is correct; it is starved of a signal that SSR sometimes never sends.

Related observation: _$HY.fe is never implemented

$df() — the streamed swap function emitted by SSR — ends with _$HY.fe(e, t), i.e. "this fragment's content is now in the DOM":

… _$HY.done ? o.remove() : o.replaceWith(n.content), n.remove(), _$HY.fe(e, t), $dfd(); return 1

generateHydrationScript installs fe(){} as a stub, and no client code ever replaces it (grepped solid-js and @solidjs/web, both src and dist). Confirmed live on a hydrated page: String(_$HY.fe) === "fe(){}". Whatever that hook was intended for, the signal is currently dropped.

Fix attempts that did not work (recorded so they aren't re-tried)

  • Making the server resolution synchronous (removing the queue(() => queue(...)) wrapper in registerFragment): <id>_fr still arrives unresolved on the client and the symptom is unchanged.
  • Resuming the boundary off _$HY.fe (implementing the stub and treating it as a second resume trigger): fixes the app end-to-end in dev and production, but regresses the existing hydration suite — 10 failures in packages/solid-web/test/hydration (all [streamed] scenarios: parity-harness, loading-late-fragment, insert-refresh-drift, refresh-hmr-stream, diagnostics), against a clean 64/64 baseline. Deferring the resume two microtasks to match the _fr timing brought it to 7 failures; dropping the DOM-state heuristic and using only the hook, 6; gating the rescue behind window.load + a macrotask, 5. The reason it can't simply be swapped in: $df firing means the fragment's markup landed, whereas _fr additionally means its serialized data settled and flushed — so resuming on fe hydrates streamed boundaries before their data is ready.

That suggests the right fix is on the emit side — guaranteeing the _fr resolution is always flushed before the stream closes (or resolving it as part of the same write that emits $df) — rather than adding a client-side fallback trigger. Alternatively fe could drive a rescue that is provably late enough to be safe, but the "safe enough" condition needs to come from someone who knows the intended contract.

Environment

  • solid-js / @solidjs/web 2.0.0-beta.28
  • @tanstack/solid-router 2.0.0-beta.28, @tanstack/router-core 1.171.14
  • Vite 8.1.5, vite-plugin-solid 3.0.0-next.19, Cloudflare workerd (@cloudflare/vite-plugin)
  • Reproduces in dev, in vite preview (built worker), and on deploy

Activity

  1. brenelz commented on Jul 30, 2026

    @brenelz
    ContributorAuthor

    Correction — this is very likely not a Solid bug. Plain Solid SSR emits the _fr resolution correctly; my host stack was truncating the stream.

    I tested the same shape with plain renderToStream (no router, no Cloudflare), using the collect() + fragmentPatches() harness from packages/solid-web/test/server/loading-dynamic-fragments.spec.tsx — a boundary suspended on an async memo read, with a plain signal alongside it:

    const message = createMemo(async () => { await wait(50); return "hello" });
    const [count] = createSignal(0);
    <Loading fallback={<span>loading…</span>}>
      <p>{message()}</p>
      <p>{count()}</p>
    </Loading>

    Result: { "2_fr": true } — the fragment record settles, no bug. And the stream tail shows why:

    …$df definitions…</script><script>$R[5]($R[4],!0);</script>

    The _fr resolution is emitted as its own trailing chunk after the $df script. In my app that final chunk never arrives — the document ends at the $df(...) script, which is exactly why _$HY.r["…_fr"] sits pending forever with no .s, and why manually calling the resolver unsticks everything.

    So the sequence of events in the report stands, but the attribution does not: Solid writes the resolution, and the host integration (@tanstack/solid-router's renderRouterToStream / stream transform, on workerd) closes the response before Solid's stream has finished draining. I'll chase it there. Apologies for the noise.

    Two things that may still be worth a look on the Solid side, though neither is the cause:

    1. _$HY.fe is never implemented. $df() ends with _$HY.fe(e, t), generateHydrationScript installs fe(){}, and nothing in solid-js or @solidjs/web (src or dist) ever replaces the stub — verified live on a hydrated page (String(_$HY.fe) === "fe(){}"). If that hook is vestigial it could go; if it's meant to be wired, it's missing.
    2. The failure mode is silent and total. When that trailing chunk is lost for any reason, the boundary waits forever, renders fallback() into a detached tree, and every binding inside it — plain signals included — is dead after hydration, with no error. A dev-mode warning for "hydration finished with boundaries still pending on an unresolved _fr" would have turned a multi-hour hunt into a one-line diagnosis. (For what it's worth, I tried resuming off fe as a client-side safety net: it fixed my app in dev and prod, but regressed 5–10 of the 64 test/hydration specs depending on how I timed it — all [streamed] scenarios — because $df means "markup landed" while _fr means "data settled and flushed". So a naive fallback trigger isn't viable; a diagnostic seems like the better ask.)
  2. changed the title [-]Streamed <Loading> boundary never resumes when its `_fr` resolution is dropped at stream close — every binding inside the boundary is dead after hydration[/-] [+]Streamed <Loading> boundary silently dies when the trailing `_fr` resolution chunk is lost (host truncates stream) — plus `_$HY.fe` is never implemented[/+] on Jul 30, 2026
  3. brenelz commented on Jul 30, 2026

    @brenelz
    ContributorAuthor

    Refinement to my correction above: the stream is not truncated. The document completes, the $df swap arrives — only the _fr resolution is never emitted.

    I captured the raw response bytes for the failing page (production build, workerd via vite preview, real browser UA — note isbot sends curl down the buffered non-streaming path, which is why my first captures looked different):

    • </html> is present at offset 3883, and the stream keeps going after it: at 4160 comes <template id="…1112"> with the boundary's content, then the $df("…1112") call. So post-</html> chunks are delivered fine.
    • The only resolution calls in the whole document are the two data ones — )($R[7],"Hello World") and )($R[4],"Hello World").
    • There is no ($R[n],!0) call anywhere — the _fr form. In plain renderToStream output that call is the final chunk (<script>$R[5]($R[4],!0);</script>), which is what I compared against.

    Matching client state on that page: _$HY.r["…1113_fr"] has no .s, while _$HY.r["…1110"] (the data) is { s: 1, v: … }.

    So registerFragment's item.resolve() either never runs or runs after the serializer has stopped accepting writes in this environment, while the fragment reveal itself is emitted normally. The two-microtask deferral (resolve: err => queue(() => queue(...))) is still my main suspect for how the resolution loses the race against stream finalization — but the earlier framing of "the host closed the response early" was wrong, and I wanted that on the record.

    One more datum, in case it's a clue rather than a separate bug: in the production build the same page hydrates worse than in dev. In dev, a plain signal outside the boundary stays live (only bindings inside the boundary are dead); in the production build that outside binding is frozen too, suggesting the whole route subtree fails to claim there. Same code, same versions — dev vs. built worker.

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