Repository navigation
Description
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
-
registerFragment(in@dom-expressions/runtime, bundled into@solidjs/web/dist/server.js) resolves the boundary's<id>_frpromise behind two microtask hops:registry.set(key, { resolve: err => queue(() => queue(() => { err ? reject(err) : resolve(true); queue(flushEnd); })) }); serializer.write(key + "_fr", p);
-
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 ❌
-
hydratedCreateLoadingBoundarytakes itswaitAndResume(fr, resume, assetPromise, false)path and waits on that promise forever —resumeBoundaryHydrationnever runs. The boundary returnsfallback(), 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"> -
Resolving that one promise by hand in the console makes everything work immediately — resume fires, the content is claimed, and
Bstarts 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 1generateHydrationScript 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 inregisterFragment):<id>_frstill 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 inpackages/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_frtiming brought it to 7 failures; dropping the DOM-state heuristic and using only the hook, 6; gating the rescue behindwindow.load+ a macrotask, 5. The reason it can't simply be swapped in:$dffiring means the fragment's markup landed, whereas_fradditionally means its serialized data settled and flushed — so resuming onfehydrates 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/web2.0.0-beta.28@tanstack/solid-router2.0.0-beta.28,@tanstack/router-core1.171.14- Vite 8.1.5,
vite-plugin-solid3.0.0-next.19, Cloudflare workerd (@cloudflare/vite-plugin) - Reproduces in dev, in
vite preview(built worker), and on deploy
Activity
brenelz commentedon Jul 30, 2026
Correction — this is very likely not a Solid bug. Plain Solid SSR emits the
_frresolution correctly; my host stack was truncating the stream.I tested the same shape with plain
renderToStream(no router, no Cloudflare), using thecollect()+fragmentPatches()harness frompackages/solid-web/test/server/loading-dynamic-fragments.spec.tsx— a boundary suspended on an async memo read, with a plain signal alongside it:Result:
{ "2_fr": true }— the fragment record settles, no bug. And the stream tail shows why:The
_frresolution is emitted as its own trailing chunk after the$dfscript. 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'srenderRouterToStream/ 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:
_$HY.feis never implemented.$df()ends with_$HY.fe(e, t),generateHydrationScriptinstallsfe(){}, and nothing insolid-jsor@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.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 offfeas a client-side safety net: it fixed my app in dev and prod, but regressed 5–10 of the 64test/hydrationspecs depending on how I timed it — all[streamed]scenarios — because$dfmeans "markup landed" while_frmeans "data settled and flushed". So a naive fallback trigger isn't viable; a diagnostic seems like the better ask.)[-]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[/+]brenelz commentedon Jul 30, 2026
Refinement to my correction above: the stream is not truncated. The document completes, the
$dfswap arrives — only the_frresolution is never emitted.I captured the raw response bytes for the failing page (production build, workerd via
vite preview, real browser UA — noteisbotsends 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.)($R[7],"Hello World")and)($R[4],"Hello World").($R[n],!0)call anywhere — the_frform. In plainrenderToStreamoutput 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'sitem.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.
One reveal owner: the fragment ledger replaces claim sets and page scans