Skip to content

fix(app-router): ensure streamed SSR body ends with </body></html> (#1532) - #1624

Merged
james-elicx merged 3 commits into
mainfrom
fix/issue-1532-streaming-html-suffix
Jun 3, 2026
Merged

fix(app-router): ensure streamed SSR body ends with </body></html> (#1532)#1624
james-elicx merged 3 commits into
mainfrom
fix/issue-1532-streaming-html-suffix

Conversation

@james-elicx

Copy link
Copy Markdown
Member

Summary

The App Router streaming SSR pipeline emitted trailing flight chunks and preinit scripts (from rscEmbed.finalize()) after React Fizz's </body></html> closing tags, leaving the body ending in <script>...</script> instead of a well-formed document close. This broke body.endsWith('</body></html>') consumer assertions.

The fix mirrors Next.js's createMoveSuffixStream: strip the first </body></html> we see in buffered chunks and re-emit it at the very end of the transform's flush(), after the final RSC scripts.

  • Code change: packages/vinext/src/server/app-ssr-stream.ts (~25 lines of net logic + comments).
  • Unit tests: 3 cases in tests/app-ssr-stream.test.ts cover trailing-script reorder, the no-<head> fallback path, and the missing-suffix defense-in-depth.
  • Integration test: 1 case in tests/app-router.test.ts exercises a real App Router render and asserts html.endsWith('</body></html>') plus that the suffix appears only once.

Reference (Next.js):

Fixes #1532.

Test plan

  • pnpm test tests/app-ssr-stream.test.ts — 21 tests pass (3 new)
  • pnpm test tests/app-router.test.ts tests/app-page-stream.test.ts — 347 tests pass (1 new)
  • pnpm test tests/features.test.ts — 309 tests pass
  • pnpm run check — formatting, lint, types clean

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review

@pkg-pr-new

pkg-pr-new Bot commented May 28, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/vinext@1624

commit: da7fec9

@github-actions

Copy link
Copy Markdown
Contributor
Example Preview Production Original
app-router-cloudflare preview production
pages-router-cloudflare preview production
app-router-playground preview production original
realworld-api-rest preview production
nextra-docs-template preview production
benchmarks preview production
hackernews preview production original
web preview production

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: fix(app-router): ensure streamed SSR body ends with </body></html>

Clean, well-scoped fix. The approach correctly mirrors Next.js's createMoveSuffixStream — strip the first </body></html> from the stream during chunk processing and unconditionally re-emit it in flush() after all trailing flight data and preinit scripts. I verified the Next.js source and the semantics match exactly, including the defense-in-depth behavior of appending the suffix even when the source stream omits it.

What I checked

  • Next.js parity: Confirmed against createMoveSuffixStream in packages/next/src/server/stream-utils/node-web-streams-helper.ts. Same strip-then-reappend pattern, same unconditional emit in flush(), same comment rationale for the missing-suffix case.
  • Existing test compatibility: The runTransform helper tests (e.g., line 270 expect(out).toBe(html)) still pass because the suffix is stripped and re-appended, producing identical output for well-formed input.
  • Mid-stream RSC scripts: RSC scripts emitted by rscEmbed.flush() in the transform() timeout (line 306) land after flushBuffered has already stripped the suffix from that batch, so they can't accidentally appear after </body></html>. Correct ordering.
  • flush() ordering: flushBuffered → fallback emitInsertionrscEmbed.finalize() → suffix. All trailing content lands before the closing tags.

One edge case worth noting (not blocking)

The suffix detection operates on individual decoded string chunks (chunk.indexOf(DOCUMENT_CLOSE_SUFFIX)). If </body></html> were ever split across two Fizz chunks (e.g., </bo in one chunk and dy></html> in the next), the indexOf would miss it. Next.js has the same theoretical limitation with indexOfUint8Array on individual chunks. In practice, React Fizz emits the closing shell as a single chunk and the tick-buffering in this transform coalesces multiple chunks before scanning, so this is not a real concern — just documenting that the trade-off is understood and matches upstream.

Tests are thorough: unit tests cover the happy path (trailing scripts reordered), the no-<head> fallback path, and the missing-suffix defense-in-depth case. The integration test exercises a real App Router render and asserts both endsWith and single-occurrence. Good coverage.

@ask-bonk

ask-bonk Bot commented May 28, 2026

Copy link
Copy Markdown
Contributor

Review posted and approved on PR #1624. Here's the summary:

Approved — clean, well-scoped fix that correctly mirrors Next.js's createMoveSuffixStream pattern.

The fix strips the first </body></html> during chunk processing and unconditionally re-emits it in flush() after all trailing flight data and preinit scripts. I verified against the Next.js source and confirmed the semantics match exactly, including the defense-in-depth case where the suffix is appended even when the source stream never contained it.

Key things verified:

  • Next.js parity with createMoveSuffixStream
  • Existing tests remain compatible (suffix stripped + re-appended = identical output)
  • Mid-stream RSC scripts can't accidentally land after the closing tags
  • flush() ordering is correct: buffered HTML → fallback injection → finalize scripts → suffix
  • Tests cover happy path, no-<head> fallback, missing-suffix defense-in-depth, and real integration render

github run

@james-elicx
james-elicx marked this pull request as ready for review May 28, 2026 10:14
@james-elicx

Copy link
Copy Markdown
Member Author

Updated tests/rsc-streaming.test.ts 'progressive delivery' case. The old assertion (donePos > lastHtmlPos) predated this PR's suffix-move; now the navigation-runtime done signal sits between body content and </body></html>, which is exactly the well-formed-close invariant #1532 ensures.

@james-elicx

Copy link
Copy Markdown
Member Author

Pushed biome formatting fix for tests/rsc-streaming.test.tsCheck was failing because the file wasn't formatted. pnpm vp check now passes locally.

…1532)

Trailing flight chunks and preinit scripts emitted by `rscEmbed.finalize()`
in the tick-buffered transform's `flush()` were being appended after React
Fizz's `</body></html>` closing tags, leaving the streamed document body
ending in `<script>...</script>` rather than a well-formed close.

Strip the first `</body></html>` we see in the buffered HTML chunks and
re-emit it at the very end of `flush()`, after the final RSC scripts.
Mirrors Next.js's `createMoveSuffixStream` behaviour
(packages/next/src/server/stream-utils/node-web-streams-helper.ts).

Ported from Next.js test:
test/e2e/app-dir/app/index.test.ts — "should ensure the </body></html>
suffix is at the end of the stream".

Fixes #1532.
The 'delivers RSC chunks progressively' case still asserted the old order
where the navigation-runtime done signal trailed </html>. After #1532
suffix-moved </body></html> to the very end of the stream, the done
signal sits *between* the body content and the document close. Update
the assertion to match: done > last body content, and </html> > done.
@james-elicx
james-elicx force-pushed the fix/issue-1532-streaming-html-suffix branch from a00dcd4 to da7fec9 Compare May 29, 2026 08:49
@james-elicx

Copy link
Copy Markdown
Member Author

Rebased on origin/main; resolved conflict in app-ssr-stream.ts where main's inline-CSS rewrite refactor and this PR's </body></html> suffix-stripping overlapped — kept both by applying stripDocumentCloseSuffix to the merged single-working flush path. vp check (format/lint/typecheck) passes.

@james-elicx
james-elicx merged commit 4ffd5bf into main Jun 3, 2026
47 of 48 checks passed
@james-elicx
james-elicx deleted the fix/issue-1532-streaming-html-suffix branch June 3, 2026 21:42
@github-actions github-actions Bot mentioned this pull request Jun 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

App Router: streaming response does not end with </body></html> suffix

1 participant