Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 7 additions & 13 deletions packages/vinext/src/server/app-page-route-wiring.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -555,13 +549,13 @@ export function createAppPageRouteBodyMetadata(
trailingSlash?: boolean,
): ReactNode {
if (!metadata || metadataPlacement !== "body") return null;
// Next.js also renders real metadata elements in its hidden streaming outlet.
// Vinext resolves metadata before rendering, so React can hoist these into the
// document head during the initial render instead of appending them after the shell.
return (
<div
hidden
dangerouslySetInnerHTML={{
__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.

<MetadataHead metadata={metadata} pathname={pathname} trailingSlash={trailingSlash} />
</div>
);
}

Expand Down
4 changes: 4 additions & 0 deletions packages/vinext/src/shims/metadata.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -860,6 +860,10 @@ function renderMetadataElementToHtml(node: unknown): string {
}
}

/**
* @deprecated Production metadata renders through {@link MetadataHead}. This
* serializer remains exported for compatibility with existing shim consumers.
*/
export function renderMetadataToHtml(
metadata: Metadata,
pathname = "/",
Expand Down
72 changes: 61 additions & 11 deletions tests/app-page-route-wiring.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,35 @@ async function renderRouteEntry(elements: AppElements, routeId: string): Promise
);
}

async function renderRouteDocument(elements: AppElements, routeId: string): Promise<string> {
const { ElementsContext, Slot } = await import("../packages/vinext/src/shims/slot.js");
return renderHtml(
createElement(
"html",
null,
createElement("head"),
createElement(
"body",
null,
createElement(
ElementsContext.Provider,
{ value: elements },
createElement(Slot, { id: routeId }),
),
),
),
);
}

function readDocumentSection(html: string, tagName: "head" | "body"): string {
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.

}
return html.slice(start, end + tagName.length + 3);
}

async function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timeoutId = setTimeout(() => {
Expand Down Expand Up @@ -303,7 +332,14 @@ async function buildGeneratedMetadataRouteHtml(
page: {
default: PageProbe,
async generateMetadata() {
return { title: "generated page" };
return {
title: "generated page",
alternates: {
canonical: "https://example.com/generated",
languages: { "en-US": "https://example.com/en/generated" },
},
robots: { index: false, follow: false },
};
},
},
params: [],
Expand All @@ -327,7 +363,7 @@ async function buildGeneratedMetadataRouteHtml(
htmlLimitedBots,
});

return renderRouteEntry(elements, "route:/generated");
return renderRouteDocument(elements, "route:/generated");
}

function RouteLoadingProbe() {
Expand Down Expand Up @@ -418,13 +454,25 @@ 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 () => {
// Next.js renders the same real elements in its streaming metadata outlet:
// https://github.com/vercel/next.js/blob/canary/packages/next/src/lib/metadata/metadata.tsx
// Vinext has already resolved metadata before this render, so React can hoist
// the elements into <head> instead of appending them after a streamed shell.
const html = await buildGeneratedMetadataRouteHtml("HeadlessChrome");

expect(html).not.toContain("<title>generated page</title><div");
expect(html).toContain('<div hidden=""><title>generated page</title></div>');
const head = readDocumentSection(html, "head");
const body = readDocumentSection(html, "body");

expect(head).toContain("<title>generated page</title>");
expect(head).toContain('rel="canonical" href="https://example.com/generated"');
expect(head).toContain('hrefLang="en-US" href="https://example.com/en/generated"');
expect(head).toContain('name="robots" content="noindex, nofollow"');
expect(body).not.toContain("<title>generated page</title>");
expect(body).not.toContain('rel="canonical"');
expect(body).not.toContain('hrefLang="en-US"');
expect(body).not.toContain('name="robots"');
// Metadata is hoisted out, leaving the body outlet empty.
expect(body).toContain('<div hidden=""></div>');
});

it("renders generated metadata in the head for configured html-limited bots", async () => {
Expand All @@ -446,12 +494,14 @@ describe("app page route wiring helpers", () => {
});

it("falls back to the default html-limited bot list for an empty config string", async () => {
// Next.js normalizes a falsy htmlLimitedBots config to the default bot regex.
// Next.js normalizes a falsy htmlLimitedBots config to the default bot regex,
// so HeadlessChrome is treated as streaming-capable and metadata is hoisted.
// https://github.com/vercel/next.js/blob/canary/packages/next/src/server/lib/streaming-metadata.ts
const html = await buildGeneratedMetadataRouteHtml("HeadlessChrome", "");

expect(html).not.toContain("<title>generated page</title><div");
expect(html).toContain('<div hidden=""><title>generated page</title></div>');
expect(html).toContain("<title>generated page</title>");
expect(html).toContain('<div hidden=""></div>');
expect(html).not.toContain('<div hidden=""><title>generated page</title>');
});

it("resolves child segments from tree positions and preserves route groups", () => {
Expand Down
25 changes: 25 additions & 0 deletions tests/e2e/app-router/metadata.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { test, expect } from "@playwright/test";
import { waitForAppRouterHydration } from "../helpers";

const BASE = "http://localhost:4174";

Expand Down Expand Up @@ -74,6 +75,30 @@ test.describe("Dynamic Metadata (generateMetadata)", () => {
const ogDescription = page.locator('meta[property="og:description"]');
await expect(ogDescription).toHaveAttribute("content", "Dynamic OG Description");
});

test("generateMetadata keeps SEO metadata in head after hydration", async ({ page }) => {
await page.goto(`${BASE}/metadata-dynamic-test`);
await waitForAppRouterHydration(page);

await expect(page).toHaveTitle("Dynamic Metadata Page");
await expect(page.locator("head title")).toHaveCount(1);
await expect(page.locator('head link[rel="canonical"]')).toHaveAttribute(
"href",
"https://example.com/metadata-dynamic-test",
);
await expect(page.locator('head link[rel="alternate"][hreflang="en-US"]')).toHaveAttribute(
"href",
"https://example.com/en/metadata-dynamic-test",
);
await expect(page.locator('head meta[name="robots"]')).toHaveAttribute(
"content",
"noindex, nofollow",
);
await expect(page.locator("body title")).toHaveCount(0);
await expect(page.locator('body link[rel="canonical"]')).toHaveCount(0);
await expect(page.locator('body link[rel="alternate"][hreflang="en-US"]')).toHaveCount(0);
await expect(page.locator('body meta[name="robots"]')).toHaveCount(0);
});
});

test.describe("Metadata Routes", () => {
Expand Down
5 changes: 2 additions & 3 deletions tests/features.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2755,7 +2755,7 @@ describe("MetadataHead rendering", () => {
expect(html).toContain('href="/apple.png"');
});

it("serializes rich metadata for the streaming body outlet", () => {
it("serializes rich metadata for compatibility", () => {
const html = renderMetadataToHtml(
{
metadataBase: new URL("https://example.com"),
Expand Down Expand Up @@ -2796,8 +2796,7 @@ describe("MetadataHead rendering", () => {
expect(html).toContain('property="og:image:type"');
expect(html).toContain('content="image/png"');
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.

expect(html).toContain('rel="icon"');
expect(html).toContain('href="/icon.png"');
expect(html).toContain('sizes="32x32"');
Expand Down
5 changes: 5 additions & 0 deletions tests/fixtures/app-basic/app/metadata-dynamic-test/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ export async function generateMetadata() {
return {
title: "Dynamic Metadata Page",
description: "Generated dynamically via generateMetadata",
alternates: {
canonical: "https://example.com/metadata-dynamic-test",
languages: { "en-US": "https://example.com/en/metadata-dynamic-test" },
},
robots: { index: false, follow: false },
openGraph: {
title: "Dynamic OG Title",
description: "Dynamic OG Description",
Expand Down
Loading