Skip to content

fix(app-router): preserve streamed metadata placement parity#2572

Open
LubomirGeorgiev wants to merge 7 commits into
cloudflare:mainfrom
LubomirGeorgiev:fix/streaming-metadata-hoist-real-elements
Open

fix(app-router): preserve streamed metadata placement parity#2572
LubomirGeorgiev wants to merge 7 commits into
cloudflare:mainfrom
LubomirGeorgiev:fix/streaming-metadata-hoist-real-elements

Conversation

@LubomirGeorgiev

@LubomirGeorgiev LubomirGeorgiev commented Jul 8, 2026

Copy link
Copy Markdown

Summary

Restore Next.js-compatible metadata placement across request-time streaming and static artifact rendering.

  • Streaming-capable runtime requests keep async generateMetadata output in <body>.
  • HTML-limited bots receive blocking metadata in <head>.
  • Full build prerenders and ISR/cache regeneration artifacts resolve metadata into <head>.
  • PPR fallback-shell behavior remains unchanged until metadata-level staticness is tracked.

Refs #2007.

Root cause

The original change rendered streamed metadata as real React elements so React 19 hoisted it into <head> on every request. That diverged from Next.js: for normal request-time streaming, delayed metadata intentionally remains in <body>. Reverting that change restored the primary metadata-streaming suite, but exposed a second distinction: complete static artifacts must place generated metadata in <head>.

Vinext uses a manually serialized metadata outlet, so it cannot infer this distinction from React flush timing. This change threads an explicit placement decision from the App Router dispatcher into element construction while keeping UA-driven bot and metadata-error semantics separate.

Verification

  • Next.js metadata-streaming.test.ts: 19/19 passed
  • Next.js customized-rule suite: 2/2 passed
  • Next.js static-generation suite: 7/7 passed
  • Next.js parallel-routes suite: 4/4 passed
  • Focused vinext unit/integration tests: 212/212 passed
  • Vinext metadata Playwright suite: 12/12 passed
  • vp check passes on all changed files
  • Two fresh independent sub-agent reviews found no remaining issues

The upstream PPR metadata-streaming suite is currently describe.skip; this change preserves existing fallback-shell placement rather than guessing at unsupported metadata staticness classification.

Async generateMetadata was serialized via dangerouslySetInnerHTML into a
hidden <div> in the body, which React cannot hoist. For JS-capable clients
(browsers, Googlebot) the tags stayed in <body>, where Google ignores
rel=canonical, hreflang and robots.

Render the resolved metadata as real <title>/<meta>/<link> elements through
MetadataHead so React 19 hoists them into <head>, matching Next.js. Remove
the now-unused renderMetadataToHtml string serializer.
@pkg-pr-new

pkg-pr-new Bot commented Jul 9, 2026

Copy link
Copy Markdown

Open in StackBlitz

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

commit: 139f303

@github-actions

github-actions Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared 139f303 against base 132d351 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 126.6 KB 126.6 KB ⚫ -0.0%
Client entry size (gzip) vinext 120.6 KB 120.6 KB ⚫ -0.0%
Dev server cold start vinext 2.81 s 2.85 s ⚫ +1.4%
Production build time vinext 2.89 s 2.86 s ⚫ -0.8%
RSC entry closure size (gzip) vinext 98.5 KB 98.6 KB ⚫ +0.1%
Server bundle size (gzip) vinext 165.2 KB 165.3 KB ⚫ +0.0%

View detailed results and traces

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

@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

@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: metadata hoisting fix

Solid, well-scoped change. I verified the core mechanism and it holds up:

Correctness — confirmed good

  • MetadataHead only ever emits <title>, <meta>, and non-stylesheet <link> elements. All three are hoisted by React 19 from anywhere in the tree, so replacing the opaque dangerouslySetInnerHTML string with real elements does move them into <head>. I read the full component (packages/vinext/src/shims/metadata.tsx:877-1389) and confirmed there are no <script> (e.g. JSON-LD) or <style> tags that React would not hoist and would leave stuck in the body.
  • resolvedMetadata is already the merged root+page metadata, so there's exactly one <title> — no risk of React's title dedup conflicting with the head path (which only emits charSet).
  • Server renders <div hidden></div> (metadata hoisted out) and the client re-renders <div hidden><MetadataHead/></div> which also hoists — no hydration mismatch.
  • No dev/prod parity gap: renderMetadataToHtml now has a single (deprecated) consumer and the body-metadata path is App-Router-only, shared between dev and prod via the RSC entry. Pages Router has no equivalent path.

Tests — ran tests/app-page-route-wiring.test.ts (44 pass) and the MetadataHead rendering block of tests/features.test.ts (39 pass). vp check clean on the changed source files. The switch to renderRouteDocument rendering a full <html>/<head>/<body> document is a genuine improvement — it actually exercises hoisting rather than asserting on the raw outlet string.

Two minor, non-blocking notes below.

Comment thread tests/features.test.ts Outdated
expect(html).toContain('rel="alternate"');
expect(html).toContain('hreflang="en-US"');
expect(html).toContain('href="https://example.com/products/en"');
expect(html).toContain('href="https://example.com/products/en" hreflang="en-US"');

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.

This assertion change isn't required by this PR. renderMetadataToHtml's serializer is unchanged (only a @deprecated doc comment was added), and the old split assertions (toContain('hreflang="en-US"') + toContain('href="https://example.com/products/en"')) still pass. Folding them into a single adjacency check couples the test to the exact attribute order emitted by renderMetadataAttributes (rel, href, hrefLang, media, type, sizes), making it brittle to a harmless serializer reorder. It does match the existing pattern at line 3234, so this is a style nit rather than a defect — feel free to keep it, but it's unrelated churn for a metadata-hoisting PR.

__html: renderMetadataToHtml(metadata, pathname, { trailingSlash }),
}}
/>
<div hidden>

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.

The <div hidden> wrapper is now purely vestigial: its metadata children are all hoisted to <head>, leaving an empty <div hidden></div> in the body (as your own test at tests/app-page-route-wiring.test.ts asserts). That's harmless and keeps a stable outlet, so no change needed — but worth a one-line comment noting the wrapper is intentionally retained as the streaming outlet placeholder, since a future reader may be tempted to drop it. Related: as the PR body notes, once the metadataPlacement UA gate is removed this body branch collapses into the head path entirely.

@ask-bonk

ask-bonk Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Review posted successfully to PR #2572.

Summary

I reviewed PR #2572 (metadata hoisting fix) thoroughly and posted a batch review with a COMMENT verdict (no blocking issues).

Verdict: LGTM — well-scoped, correct change.

Correctness verification I performed:

  • Read the full MetadataHead component — confirmed it only emits <title>, <meta>, and non-stylesheet <link> elements, all of which React 19 hoists to <head>. Critically, there are no <script> (JSON-LD) or <style> tags that React would not hoist and would leave stuck in the body.
  • Confirmed no <title> duplication risk (single merged resolvedMetadata, head path emits only charSet).
  • Confirmed no hydration mismatch (server and client both render <div hidden> that hoists to empty).
  • Confirmed dev/prod parity — the body-metadata path is App-Router-only and shared via the RSC entry; no other consumer of the old string serializer remains.
  • Ran tests/app-page-route-wiring.test.ts (44 pass), the MetadataHead rendering block of tests/features.test.ts (39 pass), and vp check (clean).

Two minor, non-blocking notes posted inline:

  1. tests/features.test.ts:2799 — the hreflang/href adjacency assertion change is unrelated churn (the serializer is unchanged) and couples the test to attribute ordering. Style nit only.
  2. app-page-route-wiring.tsx:556 — the <div hidden> wrapper is now vestigial (children hoist out); suggested a comment noting it's an intentional streaming-outlet placeholder.

github run

@james-elicx james-elicx enabled auto-merge (squash) July 9, 2026 21:44
@james-elicx james-elicx disabled auto-merge July 9, 2026 21:45
@james-elicx

Copy link
Copy Markdown
Member

There's a regression in the streamed metadata nextjs e2e. Will look at fixing.

@james-elicx james-elicx changed the title fix(app-router): hoist streamed metadata into <head> instead of body fix(app-router): preserve streamed metadata placement parity Jul 9, 2026
@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

@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: streamed metadata placement parity

I traced the full placement mechanism end-to-end and the change is sound and well-scoped. Notes below, plus one open item.

Mechanism is correct

The two placement modes are asymmetric by design, and the code gets both right:

  • metadataPlacement === "body" serializes metadata via renderMetadataToHtml into a <div hidden dangerouslySetInnerHTML> (app-page-route-wiring.tsx:557-565). Because that markup is opaque innerHTML, React 19 does not hoist it — it stays in <body>. That is exactly what restores Next.js request-time streaming parity.
  • metadataPlacement === "head" renders real <MetadataHead> elements (app-page-route-wiring.tsx:543-545), which React 19 hoists into <head>.

(For the record: the earlier bot review described this inverted — it claimed the fix replaces dangerouslySetInnerHTML with real elements to move metadata to head. It's the opposite: the body path uses the serializer to keep metadata in body. The conclusion still holds, but the mental model in that review was backwards.)

Threading is consistent and backward-compatible

  • placeGeneratedMetadataInBody = (!isPrerender || pprFallbackShell !== undefined) && serveStreamingMetadata (app-page-dispatch.ts:577-578) correctly forces head placement for full prerenders while preserving request-time placement for PPR fallback shells.
  • ISR/cache regeneration hardcodes serveStreamingMetadata: false (app-page-dispatch.ts:734) — correct, since the stored artifact must have metadata resolved into <head>.
  • The serveStreamingMetadata ?? shouldServeStreamingMetadata(...) fallback in the builder (app-page-element-builder.ts:366) is backward-compatible: dispatch always passes a boolean, other callers pass undefined and keep the old UA-based behavior.
  • Special-error paths correctly keep the raw UA-based serveStreamingMetadata (not placeGeneratedMetadataInBody), because there it drives HTTP semantics (redirect refresh-tag vs 307, notFound 200 vs error status in app-page-execution.ts:333/453), not element placement. Good separation.

Verification

  • tests/app-page-route-wiring.test.ts (46) + tests/app-page-dispatch.test.ts (63) pass locally.
  • tests/nextjs-compat/metadata.test.ts + metadata-suspense.test.ts (48) pass locally.

Open item (already flagged by @james-elicx)

The noted regression in the Next.js streamed-metadata e2e is the thing to confirm green in CI before merge — I can't reproduce the full Playwright/Next.js e2e suite quickly here, and it's outside the vitest coverage that passes. That's the gating check.

Two minor non-blocking notes inline.

const start = html.indexOf(`<${tagName}>`);
const end = html.indexOf(`</${tagName}>`);
if (start === -1 || end === -1) {
throw new Error(`Rendered document is missing <${tagName}>`);

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.

readDocumentSection matches bare <head>/<body> via indexOf("<head>"). That works because renderRouteDocument emits attribute-less tags, but it will silently break if the document ever renders <body class=...> or similar (the indexOf returns -1 and throws "missing "). Fine for this test as written — just noting the coupling in case the document wrapper grows attributes later.

await waitForAppRouterHydration(page);

await expect(page).toHaveTitle("Dynamic Metadata Page");
await expect(page.locator("head title")).toHaveCount(0);

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.

Nice — asserting head title count is 0 and body title count is 1 after hydration is the right guard: it proves the client re-render of the opaque <div hidden dangerouslySetInnerHTML> doesn't leak metadata into <head>. This is the case the unit tests can't cover (they only render the server tree). Good addition.

@ask-bonk

ask-bonk Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Review posted successfully to PR #2572.

Summary

I traced the streamed-metadata placement mechanism end-to-end and posted a COMMENT review (no blocking code issues from me).

Verdict: correct and well-scoped. The change is sound.

What I verified:

  1. Mechanism is correct (and asymmetric by design):

    • body placement uses renderMetadataToHtml<div hidden dangerouslySetInnerHTML> — opaque innerHTML that React 19 does not hoist, so it stays in <body> (request-time streaming parity).
    • head placement uses real <MetadataHead> elements that React 19 hoists to <head>.
    • Notably, the earlier bot review had this inverted (claimed the fix replaces dangerouslySetInnerHTML with real elements to move metadata to head). It's the opposite — the body path uses the serializer to keep metadata in body. I flagged the corrected mental model in the review.
  2. Threading is consistent/backward-compatible: placeGeneratedMetadataInBody correctly forces head for full prerenders but keeps body for PPR fallback shells; ISR regeneration hardcodes false; the ?? fallback preserves old UA-based behavior for other callers.

  3. Special-error separation is correct: those paths keep the raw UA-based serveStreamingMetadata because there it drives HTTP semantics (redirect/notFound status), not placement.

  4. Tests pass locally: wiring (46) + dispatch (63) + nextjs-compat metadata (48).

Gating open item: I explicitly called out the maintainer's own note about the Next.js streamed-metadata e2e regression as the check to confirm green in CI before merge — that's outside the vitest coverage I could run locally.

Two minor inline notes: readDocumentSection's naive indexOf coupling, and a positive note confirming the post-hydration E2E assertion is the right guard.

github run

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.

2 participants