Skip to content

fix(app-router): align blocking and streaming metadata rendering - #3334

Open
Divkix wants to merge 1 commit into
cloudflare:mainfrom
Divkix:issue-3320
Open

Divkix wants to merge 1 commit into
cloudflare:mainfrom
Divkix:issue-3320

Conversation

@Divkix

@Divkix Divkix commented Sep 20, 2026

Copy link
Copy Markdown
Contributor

Fixes #3320

Summary

  • Generated metadata now renders through the same hidden Suspense boundary in blocking (HTML-limited bot / prerender) and streaming placement. A blocker element inside that boundary holds the response shell on the same metadata resolution, so the tags hoist into the still-open head while the body keeps streaming, and the blocking path hydrates from the same tree as the streaming path instead of a separate eager head render.
  • PPR fallback shells are bypassed for requests that require blocking metadata: a fallback shell has already closed its head before the dynamic render resumes, so tags resolved during the resume could never reach the document head.
  • Ported from Next.js vercel/next.js@7d42c9d; upstream coverage: test/e2e/app-dir/metadata-streaming-cache-components/metadata-streaming-cache-components-custom-bots.test.ts.

Files: server/app-page-element-builder.ts, server/app-page-route-wiring.tsx, server/app-page-ppr-runtime.ts.

Test plan

New tests (each fails if its source change is reverted):

  • tests/app-page-element-builder.test.tsbuilds blocking-placement elements without awaiting generated metadata (times out on the pre-fix eager await).
  • tests/app-page-dispatch.test.tsbypasses the PPR fallback shell for an html-limited bot and renders the page (plus a streaming-capable control that still serves the shell).
  • tests/app-router-production-server.test.ts — bot gets title/description in </head> while metadata-streaming-bot-content streams in a later chunk; browser gets the title streamed in the body. New fixture tests/fixtures/app-basic/app/metadata-streaming-bot/page.tsx.
  • tests/e2e/app-router/nextjs-compat/metadata.spec.ts — bot-UA hydration: one head title, no body title, streamed content visible, zero console/page errors.

Updated: routes html-limited bot metadata errors through an unsuspended outlet (both placements now share the boundary).

Local CI parity run before pushing: vp run build, vp check, vp run knip, node scripts/ci-integration-shard.mjs --check --shard-total=10, vp test run --project unit (10200 passed; only pre-existing macOS-only failure), full vp test run (12256 passed; remaining failures reproduce at base or pass in isolation), and Playwright projects app-router, app-router-isr-prod, app-router-bfcache, ppr-impact-demo, app-router-chrome-browser-specific.

Note: the new bot tests use Twitterbot/1.0 rather than Googlebot — Googlebot executes JS, so it is a headless-browser bot that streams, not an HTML-limited bot (utils/html-limited-bots.ts).

@pkg-pr-new

pkg-pr-new Bot commented Sep 20, 2026

Copy link
Copy Markdown

Open in StackBlitz

@vinext/cloudflare

npm i https://pkg.pr.new/@vinext/cloudflare@3334

create-vinext-app

npm i https://pkg.pr.new/create-vinext-app@3334

@vinext/types

npm i https://pkg.pr.new/@vinext/types@3334

vinext

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

@cloudflare/workers-response-store

npm i https://pkg.pr.new/@cloudflare/workers-response-store@3334

commit: dd254ea

@github-actions

github-actions Bot commented Sep 20, 2026

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared dd254ea against base e97de29 using alternating same-runner rounds. Next.js was unchanged and skipped.

0 improved · 0 regressed · 6 within ±1.5%

Scenario Framework Baseline Current Change
Client bundle size (gzip) vinext 142.2 KB 142.2 KB ⚫ +0.0%
Client entry size (gzip) vinext 129.6 KB 129.6 KB ⚫ +0.0%
Dev server cold start vinext 3.06 s 3.07 s ⚫ +0.2%
Production build time vinext 3.50 s 3.49 s ⚫ -0.3%
RSC entry closure size (gzip) vinext 121.0 KB 121.1 KB ⚫ +0.1%
Server bundle size (gzip) vinext 218.3 KB 218.4 KB ⚫ +0.1%

View detailed results and traces

🟢 improvement · 🔴 regression · ⚫ change below 1.5% · paired base/head

Render generated metadata through the same hidden Suspense boundary in
streaming and blocking placement, and pair the boundary with a blocker that
holds the response shell on that same resolution. HTML-limited bots keep
receiving the tags in the initial document head while the body continues
streaming, and the blocking path hydrates from the same tree as the streaming
path instead of a separate eager head render.

Bypass PPR fallback shells for requests that require blocking metadata: a
fallback shell has already closed its head before the dynamic render resumes,
so tags resolved during the resume could never reach the document head.

Ported from Next.js vercel/next.js@7d42c9d, upstream coverage in
test/e2e/app-dir/metadata-streaming-cache-components/metadata-streaming-cache-components-custom-bots.test.ts.

Tests:
- builds blocking-placement elements without awaiting generated metadata (new)
- bypasses the PPR fallback shell for an html-limited bot and renders the page (new)
- keeps blocking metadata in the head for an HTML-limited bot while the body streams (new)
- streams generated metadata into the body for a DOM-capable browser (new)
- blocks the shell on generated metadata and hydrates without errors (Playwright, new)
@Divkix

Divkix commented Sep 20, 2026

Copy link
Copy Markdown
Contributor Author

CI note: the first run (8ac2acf) failed in E2E (app-router 1/3). The app-basic dev server's dependency scan crashed on packages/vinext/dist/deps/.pnpm/ua-parser-js@1.0.35/deps/ua-parser-js/src/ua-parser.js ([PARSE_ERROR] A module cannot have multiple default exports), so the web server never became ready and the shard's tests all hit ERR_CONNECTION_REFUSED.

That failure is unrelated to this change:

  • shards 2/3 and 3/3 of the same commit started the same dev server successfully, and the same app-router suite passed locally in full (622 tests);
  • the bundled dep file has exactly one export default and is reachable only through shims/server.tsshims/user-agent.ts, which this PR does not touch;
  • the same dep-scan message appears in successful runs on main (e.g. runs 35226907470, 35225081082), i.e. it is an intermittent toolchain artifact that is usually tolerated.

Re-triggered by amending to dd254ea0 (identical tree, no content change) — all checks green.

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 metadata: keep blocking and streaming rendering structures aligned

1 participant