App Router: streamed Suspense chunk is never spliced, leaving a hidden duplicate of the subtree in <body> since beta.9
Summary
On a route whose layout renders a <Suspense> boundary, the server streams that
boundary's content as <div hidden id="S:0">…</div> followed by
$RC("B:0","S:0") — correctly. In the browser the splice never completes: the
boundary's content ends up rendered in place, the <!--$?--> / <!--/$-->
markers are gone, and div#S:0 is still in the document, hidden, holding a
second copy of the entire subtree.
The served HTML is correct. The damage is in what the client is left with.
Impact
The abandoned copy is hidden, so it never paints — but it is still in the DOM,
still in the accessibility tree, and still owns every id in that subtree.
- Two
<main>landmarks. A screen reader offers a landmark list with two "main" regions, and "skip to main content" becomes ambiguous. - Duplicate ids. In our app:
main,period-from,period-to. Every<label for>andgetElementByIdbinds to whichever copy is first in document order. That happens to be the live one here, but only by luck — nothing guarantees the ordering.
It is an accessibility and DOM-correctness bug rather than a visual one, which is why it can sit unnoticed: nothing looks wrong on screen. It also roughly doubles the delivered DOM for the affected subtree.
In our case getElementById('period-from') happens to return the live node
because the live copy precedes the orphan in document order — but that is an
ordering accident, not a guarantee.
Environment
vinext1.0.0-beta.10 (currentlatest). Introduced in 1.0.0-beta.9; 1.0.0-beta.8 and earlier are unaffected (bisect below)@vinext/cloudflare1.0.0-beta.8- React 19.3.0, App Router, Cloudflare Workers target
- Reproduces in both
vinext dev(macOS, Node 24) and in a deployed Cloudflare Workers build — this is not a dev-only artifact
What the server sends (correct)
Identical in dev and on the deployed Worker. For /dashboard/account/profile
the SSR response contains, in this order:
| check | value |
|---|---|
<main occurrences |
1 |
<!--$?--> (pending marker) |
1 |
<!--/$--> (close marker) |
1 |
<template id="B:0"></template> |
present |
<div hidden id="S:0"> |
present |
$RC("B:0","S:0") |
1 |
id="period-from" occurrences |
1 |
Ordering verified: B:0 before S:0 before the $RC call.
What the browser is left with
Measured after load, on every reload:
document.querySelectorAll('main').length // 2
document.querySelectorAll('#period-from').length // 2
[...document.body.children].filter(e => /^S:\d+$/.test(e.id)) // [div#S:0]
document.getElementById('S:0').hasAttribute('hidden') // trueAncestry of the two <main> elements:
body.font-sans > div#page > div.h-full.min-h-0 > div.flex.grow > main#main (visible, width 918)
body.font-sans > div#S:0 > div.flex.grow > main#main (hidden, width 0)No Suspense marker comments remain anywhere in the document
(TreeWalker(SHOW_COMMENT) over <body> returns none), so by the time $RC
could run, its target no longer exists. $RC removes the holder when it
succeeds; the holder is still here.
No console output. No hydration warning, no recoverable-error report, no
mismatch notice — only Vite's own [vite] connecting… / connected..
Timing
Deterministic, not a race. Sampling the DOM every 100 ms from navigation:
t=0ms orphan present
t=300ms orphan presentIt never resolves on its own; the duplicate is permanent for the life of the page.
Scope
Every route under the layout that owns the boundary. On the deployed Worker:
| route | <main> |
orphans | duplicate ids |
|---|---|---|---|
/dashboard |
2 | 1 | period-from, period-to, main |
/dashboard/account/profile |
2 | 1 | period-from, period-to, main |
/dashboard/account/messages |
2 | 1 | period-from, period-to, main |
Routes with no streamed boundary are unaffected — every public route on the same
deployment reports pending=0, S_chunks=0, RC=0 and a single <main>.
On the deployed Worker the orphan is also permanent: sampled every 200 ms for
5 s after readyState === 'complete', div#S:0 is present in all 25 samples.
The live <main> measures 2008 px wide and the one inside div#S:0 measures 0.
The boundary
The layout wraps its chrome in a single boundary so that useSearchParams
readers below it do not opt the tree into dynamic rendering:
<Suspense fallback={null}>
<SearchQueryProvider>
<PeriodContextProvider>{/* header, nav, <main> … */}</PeriodContextProvider>
</SearchQueryProvider>
</Suspense>Something inside it suspends during SSR and resolves shortly after the shell
flushes — which is what produces the out-of-order chunk. The layout itself is an
async server component that awaits auth and database reads, and is dynamic
implicitly (it reads cookies); it has no force-dynamic export.
Version bisect
Same app, same page, same measurement; only the vinext version changed
(@vinext/cloudflare left at 1.0.0-beta.8 throughout, dev server restarted for
each):
| vinext | <main> |
orphan | duplicate ids | verdict |
|---|---|---|---|---|
| 1.0.0-beta.5 | 1 | — | — | OK |
| 1.0.0-beta.6 | 1 | — | — | OK |
| 1.0.0-beta.7 | 1 | — | — | OK |
| 1.0.0-beta.8 | 1 | — | — | OK |
| 1.0.0-beta.9 | 2 | S:0 |
period-from, period-to, main |
BROKEN |
| 1.0.0-beta.10 | 2 | S:0 |
period-from, period-to, main |
BROKEN |
Introduced in 1.0.0-beta.9.
The working versions are not working because they avoid streaming. beta.8 emits
exactly the same thing beta.10 does — one <!--$?-->, one
<template id="B:0">, one <div hidden id="S:0"> and one
$RC("B:0","S:0") — and splices it correctly, ending with a single <main> and
no leftover holder. beta.5 likewise streams (1 pending marker, 1 $RC) and
splices. So the change is in what the client does with a chunk that is emitted
the same way, not in whether one is emitted.
Commits in the window worth a look
Between the beta.8 and beta.9 release commits (2026-08-20 → 2026-09-02) there
are 33 commits, of which two touch
packages/vinext/src/server/app-browser-entry.ts:
2a2ce31e— fix(app-router): support soft navigation in static exports (#3112)8bb09481— feat(cloudflare): prewarm canonical ISR RSC requests (#3002)
I have not confirmed either as the cause; they are simply the commits in the regression window that touch the client bootstrap.
Minimal reproduction — not achieved, and that may be informative
I tried to reduce this in a fresh [email protected] project on the
same versions, and could not. Variants tried:
<Suspense fallback={null}>+ auseSearchParamsreader, pageforce-dynamic→ boundary does not stream at all (0 chunks).- Same, but dynamic via
await cookies()in an async layout rather thanforce-dynamic→ still does not stream. - Boundary suspending on
use(promise)that settles 50 ms later → does stream (1 pending marker, 1S:0, 1$RC) and splices correctly: 1<main>, no orphan. - As (3) but with the gate resolving immediately in the browser and only suspending on the server → also streams and also splices correctly.
- As (4) but with the boundary moved into a nested async layout rather than the root layout → stops streaming again.
So "a boundary that streams" is not on its own sufficient — in a small app the splice works. Whatever breaks it in the real app is more specific, and I would rather report that honestly than ship a repro that does not reproduce. Happy to run any instrumentation you want against the app that does show it.
Workaround in use
Removing holders that no $RC ever claimed, once the document has finished
loading (every $RC is an inline script, so a holder still present at load is
one nothing is coming for — sweeping earlier could delete a chunk whose $RC
had not yet run, which would lose content):
function ssrOrphanedChunks(doc: Document): readonly Element[] {
return [...doc.body.children].filter(
(node) => node.tagName === 'DIV' && node.hasAttribute('hidden') && /^S:\d+$/.test(node.id)
);
}That restores 1 <main> and removes the duplicate ids.
Possibly related
- #2007 — async
generateMetadatatags stranded in a<div hidden>in<body>(same "streamed content left in the body" family, different cause). - #3069 — streaming metadata hydration mismatch (hidden metadata div vs script).
- #1723 / #2575 — premature Suspense fallback flush. That fix is in beta.10 and this boundary still streams, so it looks like a separate step: not whether the chunk is emitted, but that nothing splices it.
Source: cloudflare/vinext