From 24704d191d7bd04b500a3e8415289e7802eb5663 Mon Sep 17 00:00:00 2001 From: Lubomir Georgiev Date: Thu, 9 Jul 2026 02:16:53 +0700 Subject: [PATCH 1/5] fix(app-router): hoist streamed metadata into instead of body Async generateMetadata was serialized via dangerouslySetInnerHTML into a hidden
in the body, which React cannot hoist. For JS-capable clients (browsers, Googlebot) the tags stayed in , where Google ignores rel=canonical, hreflang and robots. Render the resolved metadata as real /<meta>/<link> elements through MetadataHead so React 19 hoists them into <head>, matching Next.js. Remove the now-unused renderMetadataToHtml string serializer. --- .../src/server/app-page-route-wiring.tsx | 17 ++--- packages/vinext/src/shims/metadata.tsx | 68 ------------------- 2 files changed, 4 insertions(+), 81 deletions(-) diff --git a/packages/vinext/src/server/app-page-route-wiring.tsx b/packages/vinext/src/server/app-page-route-wiring.tsx index 28e0bafa26..624dfd894e 100644 --- a/packages/vinext/src/server/app-page-route-wiring.tsx +++ b/packages/vinext/src/server/app-page-route-wiring.tsx @@ -20,13 +20,7 @@ import { AppRouterScrollTarget } from "vinext/shims/app-router-scroll"; import DefaultGlobalError from "vinext/shims/default-global-error"; import type { AppRouteSemanticIds } from "../routing/app-route-graph.js"; import { LayoutSegmentProvider } from "vinext/shims/layout-segment-context"; -import { - MetadataHead, - ViewportHead, - renderMetadataToHtml, - type Metadata, - type Viewport, -} from "vinext/shims/metadata"; +import { MetadataHead, ViewportHead, type Metadata, type Viewport } from "vinext/shims/metadata"; import { Children, ParallelSlot, Slot } from "vinext/shims/slot"; import type { AppPageParams } from "./app-page-boundary.js"; import type { AppLayoutParamAccessTracker } from "./app-layout-param-observation.js"; @@ -556,12 +550,9 @@ export function createAppPageRouteBodyMetadata( ): ReactNode { if (!metadata || metadataPlacement !== "body") return null; return ( - <div - hidden - dangerouslySetInnerHTML={{ - __html: renderMetadataToHtml(metadata, pathname, { trailingSlash }), - }} - /> + <div hidden> + <MetadataHead metadata={metadata} pathname={pathname} trailingSlash={trailingSlash} /> + </div> ); } diff --git a/packages/vinext/src/shims/metadata.tsx b/packages/vinext/src/shims/metadata.tsx index 23c5f2a55f..a70cdc174c 100644 --- a/packages/vinext/src/shims/metadata.tsx +++ b/packages/vinext/src/shims/metadata.tsx @@ -802,74 +802,6 @@ type MetadataHeadProps = { trailingSlash?: boolean; }; -function escapeHtmlText(value: string): string { - return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">"); -} - -function escapeHtmlAttribute(value: string): string { - return escapeHtmlText(value).replaceAll('"', """); -} - -function renderMetadataText(node: unknown): string { - if (node === null || node === undefined || typeof node === "boolean") return ""; - if (Array.isArray(node)) return node.map(renderMetadataText).join(""); - if (typeof node === "string" || typeof node === "number" || typeof node === "bigint") { - return escapeHtmlText(String(node)); - } - return ""; -} - -function renderMetadataAttributes(props: object, names: readonly string[]): string { - const attributes: string[] = []; - for (const name of names) { - const value = Reflect.get(props, name); - if (value === null || value === undefined || typeof value === "boolean") continue; - const htmlName = name === "hrefLang" ? "hreflang" : name; - attributes.push(`${htmlName}="${escapeHtmlAttribute(String(value))}"`); - } - return attributes.length > 0 ? ` ${attributes.join(" ")}` : ""; -} - -function renderMetadataElementToHtml(node: unknown): string { - if (node === null || node === undefined || typeof node === "boolean") return ""; - if (Array.isArray(node)) return node.map(renderMetadataElementToHtml).join(""); - if (!React.isValidElement(node)) return renderMetadataText(node); - - const props = typeof node.props === "object" && node.props !== null ? node.props : {}; - if (node.type === React.Fragment) { - return renderMetadataElementToHtml(Reflect.get(props, "children")); - } - if (typeof node.type !== "string") return ""; - - switch (node.type) { - case "title": - return `<title>${renderMetadataText(Reflect.get(props, "children"))}`; - case "meta": - return ``; - case "link": - return ``; - default: - return ""; - } -} - -export function renderMetadataToHtml( - metadata: Metadata, - pathname = "/", - options?: { trailingSlash?: boolean }, -): string { - return renderMetadataElementToHtml( - MetadataHead({ metadata, pathname, trailingSlash: options?.trailingSlash }), - ); -} - export function MetadataHead({ metadata, pathname = "/", trailingSlash }: MetadataHeadProps) { const elements: React.ReactElement[] = []; let key = 0; From dfca03195fb8ac93eb73bad83fbf79a2de1d6322 Mon Sep 17 00:00:00 2001 From: Lubomir Georgiev Date: Thu, 9 Jul 2026 02:55:24 +0700 Subject: [PATCH 2/5] Fix tests --- tests/app-page-route-wiring.test.ts | 26 ++++++++++++------ tests/features.test.ts | 41 ++++++++++++++++++++++------- 2 files changed, 50 insertions(+), 17 deletions(-) diff --git a/tests/app-page-route-wiring.test.ts b/tests/app-page-route-wiring.test.ts index 0127d2aabd..ad5e87d2bd 100644 --- a/tests/app-page-route-wiring.test.ts +++ b/tests/app-page-route-wiring.test.ts @@ -418,13 +418,21 @@ describe("app page route wiring helpers", () => { }); }); - it("renders generated metadata in a hidden body outlet for streaming-capable requests", async () => { - // Ported from Next.js: test/e2e/app-dir/metadata-streaming/metadata-streaming.test.ts - // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/metadata-streaming/metadata-streaming.test.ts + it("hoists generated metadata into the head for streaming-capable requests", async () => { + // The streamed-body branch renders MetadataHead's real /<meta>/<link> + // elements, which React 19 hoists ahead of the route content (into <head>). + // A dangerouslySetInnerHTML string would instead strand them in the body, + // where crawlers ignore rel=canonical / hreflang / robots. const html = await buildGeneratedMetadataRouteHtml("HeadlessChrome"); - expect(html).not.toContain("<title>generated page
'); + expect(html).toContain("generated page"); + // Metadata is hoisted out, leaving the body outlet empty. + expect(html).toContain(''); + expect(html).not.toContain(''); + expect(html).toContain("generated page"); + expect(html).toContain(''); + expect(html).not.toContain('