Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
36 changes: 22 additions & 14 deletions packages/vinext/src/shims/head.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,11 +103,7 @@ export function getSSRHeadHTML(): string {
// (not next/head), they don't appear in this collector — so emitting
// `defaultHead + user + initialProps` here matches Next.js's serialised
// output up to that boundary.
return reduceHeadChildren([
...defaultHead(),
..._getSSRHeadChildren(),
..._getDocumentInitialHead(),
])
return reduceHeadChildren([..._getSSRHeadChildren(), ..._getDocumentInitialHead()], defaultHead())
.map((child) => headChildToHTML(child.type as string, child.props as Record<string, unknown>))
.filter(Boolean)
.join("");
Expand Down Expand Up @@ -251,16 +247,28 @@ function createUniqueHeadFilter(): (child: React.ReactElement) => boolean {
};
}

export function reduceHeadChildren(headChildren: React.ReactNode[]): React.ReactElement[] {
return headChildren
/**
* Reduce `<Head>` children to the deduplicated list of head tags.
*
* `defaults` (normally `defaultHead()`) are placed ahead of the user tags but
* are deliberately NOT passed through `Children.toArray`, mirroring Next.js's
* `reduceComponents()`, which concatenates `defaultHead()` after flattening
* the user's children. That keeps their raw `key="charset"` / `key="viewport"`
* (no `$` separator), so the dedupe filter treats them as unkeyed and a plain
* user `<meta name="viewport">` replaces the default instead of rendering
* next to it.
*/
export function reduceHeadChildren(
headChildren: React.ReactNode[],
defaults: React.ReactElement[] = [],
): React.ReactElement[] {
const userElements = headChildren
.reduce<React.ReactNode[]>(
(flattenedChildren, child) => flattenedChildren.concat(Children.toArray(child)),
[],
)
.reduce(collectHeadElements, [])
.reverse()
.filter(createUniqueHeadFilter())
.reverse();
.reduce(collectHeadElements, []);
return [...defaults, ...userElements].reverse().filter(createUniqueHeadFilter()).reverse();
}

/**
Expand Down Expand Up @@ -507,8 +515,8 @@ export function _removeClientHeadInstance(instanceId: symbol): void {
* tags — matching the SSR path in getSSRHeadHTML() and Next.js's
* reduceComponents(), which always concatenates defaultHead() on both server
* and client. Without it the first <Head> mount after hydration would drop the
* server-rendered defaults. Users can still override via key="charset" /
* key="viewport" through the dedupe pipeline.
* server-rendered defaults. A user `<meta charSet>` or `<meta name="viewport">`
* (keyed or not) replaces the matching default through the dedupe pipeline.
*
* @internal — exported for unit tests; called from the Head client effect.
*/
Expand All @@ -522,7 +530,7 @@ export function _syncClientHead(): void {
const charsetEl = headEl.querySelector("meta[charset]");
if (charsetEl) oldTags.add(charsetEl);

const desiredHead = reduceHeadChildren([...defaultHead(), ..._clientHeadChildren.values()]);
const desiredHead = reduceHeadChildren([..._clientHeadChildren.values()], defaultHead());
const title = desiredHead.find((child) => child.type === "title");
const titleChildren = (title?.props as { children?: React.ReactNode } | undefined)?.children;
const titleText =
Expand Down
22 changes: 22 additions & 0 deletions tests/e2e/pages-router/head-and-dynamic.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,28 @@ test.describe("next/head client-side updates", () => {
const charset = await page.locator('meta[charSet="utf-8"]').count();
expect(charset).toBeGreaterThan(0);
});

// Issue #4593: a page viewport replaces the default after hydration, and
// client navigation to a page without one restores the default.
test("page viewport replaces the default through hydration and navigation", async ({ page }) => {
const viewports = page.locator('head meta[name="viewport"]');

await page.goto(`${BASE}/head-custom-viewport`);
await expect(page).toHaveTitle("Custom viewport - vinext");
await page.waitForFunction(() => (window as any).__VINEXT_ROOT__);
await expect(viewports).toHaveCount(1);
await expect(viewports).toHaveAttribute("content", "width=device-width, initial-scale=1");

await page.click('a[href="/about"]');
await expect(page).toHaveTitle("About - vinext");
await expect(viewports).toHaveCount(1);
await expect(viewports).toHaveAttribute("content", "width=device-width");

await page.goBack();
await expect(page).toHaveTitle("Custom viewport - vinext");
await expect(viewports).toHaveCount(1);
await expect(viewports).toHaveAttribute("content", "width=device-width, initial-scale=1");
});
});

test.describe("next/dynamic", () => {
Expand Down
17 changes: 17 additions & 0 deletions tests/fixtures/pages-basic/pages/head-custom-viewport.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import Head from "next/head";
import Link from "next/link";

// Issue #4593: a page-level viewport must replace the default viewport
// instead of rendering next to it.
export default function HeadCustomViewportPage() {
return (
<div>
<Head>
<title>Custom viewport - vinext</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
</Head>
<h1>Custom viewport</h1>
<Link href="/about">Go to About</Link>
</div>
);
}
244 changes: 240 additions & 4 deletions tests/head.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -668,8 +668,8 @@ describe("Head default tags (charset/viewport ordering, issue #1569)", () => {
});

it("user-supplied viewport with key='viewport' overrides the default", () => {
// The default viewport carries `key="viewport"`. User tags must use the
// same key to dedupe — matches Next.js's `key`-based merge semantics.
// A keyed user viewport replaces the default through the `name` dedupe,
// the same as an unkeyed one (see the issue #4593 tests below).
ReactDOMServer.renderToString(
React.createElement(
Head,
Expand All @@ -694,6 +694,188 @@ describe("Head default tags (charset/viewport ordering, issue #1569)", () => {
});
});

// ─── user viewport replaces the default (issue #4593) ──────────────────────
//
// Next.js's `reduceComponents()` flattens only the user's <Head> children with
// `Children.toArray` and appends `defaultHead()` afterwards, so the default
// viewport keeps its raw `key="viewport"` (no `$` separator). The `unique()`
// filter therefore treats it as unkeyed and dedupes it by `name`, which lets a
// plain user `<meta name="viewport">` replace it.
// https://github.com/vercel/next.js/blob/canary/packages/next/src/shared/lib/head.tsx

describe("Head user viewport replaces the default (issue #4593)", () => {
beforeEach(() => {
resetSSRHead();
setDocumentInitialHead([]);
});

function countViewports(html: string): number {
return (html.match(/name="viewport"/g) ?? []).length;
}

it("renders exactly one default viewport when the user sets none", () => {
ReactDOMServer.renderToString(
React.createElement(Head, null, React.createElement("title", null, "No viewport")),
);

const html = getSSRHeadHTML();
expect(countViewports(html)).toBe(1);
expect(html).toContain(
'<meta name="viewport" content="width=device-width" data-next-head="" />',
);
});

it("replaces the default viewport with an unkeyed user viewport", () => {
ReactDOMServer.renderToString(
React.createElement(
Head,
null,
React.createElement("meta", {
name: "viewport",
content: "width=device-width, initial-scale=1",
}),
),
);

expect(getSSRHeadHTML()).toBe(
'<meta charset="utf-8" data-next-head="" />' +
'<meta name="viewport" content="width=device-width, initial-scale=1" data-next-head="" />',
);
});

// Ported from Next.js: test/development/pages-dir/client-navigation/rendering-head.test.ts
// https://github.com/vercel/next.js/blob/canary/test/development/pages-dir/client-navigation/rendering-head.test.ts
// (fixture: pages/head-priority.js, "should place charset element at the top of <head>")
it("keeps user charset, viewport and title in user order with no defaults", () => {
ReactDOMServer.renderToString(
React.createElement(
Head,
null,
React.createElement("meta", { charSet: "iso-8859-5" }),
React.createElement("meta", {
name: "viewport",
content: "width=device-width,initial-scale=1",
}),
React.createElement("meta", { name: "title", content: "head title" }),
),
);

expect(getSSRHeadHTML()).toBe(
'<meta charset="iso-8859-5" data-next-head="" />' +
'<meta name="viewport" content="width=device-width,initial-scale=1" data-next-head="" />' +
'<meta name="title" content="head title" data-next-head="" />',
);
});

// Ported from Next.js: test/development/pages-dir/client-navigation/rendering-head.test.ts
// https://github.com/vercel/next.js/blob/canary/test/development/pages-dir/client-navigation/rendering-head.test.ts
// (fixture: pages/head-duplicate-default-keys.js)
it("dedupes tags keyed like the defaults across multiple Head instances", () => {
ReactDOMServer.renderToString(
React.createElement(
React.Fragment,
null,
React.createElement(
Head,
null,
React.createElement("meta", { charSet: "utf-8", key: "charSet" }),
React.createElement("meta", { charSet: "iso-8859-5", key: "charSet" }),
React.createElement("meta", { name: "viewport", content: "width=500", key: "viewport" }),
),
React.createElement(
Head,
null,
React.createElement("meta", { charSet: "iso-8859-1", key: "charSet" }),
),
),
);

const html = getSSRHeadHTML();
expect((html.match(/charset=/g) ?? []).length).toBe(1);
expect(countViewports(html)).toBe(1);
expect(html).toContain('<meta charset="iso-8859-1" data-next-head="" />');
expect(html).toContain('<meta name="viewport" content="width=500" data-next-head="" />');
});

it("lets the last unkeyed viewport win across multiple Head instances", () => {
ReactDOMServer.renderToString(
React.createElement(
React.Fragment,
null,
React.createElement(
Head,
null,
React.createElement("meta", { name: "viewport", content: "width=320" }),
),
React.createElement(
Head,
null,
React.createElement("meta", { name: "viewport", content: "width=640" }),
),
),
);

const html = getSSRHeadHTML();
expect(countViewports(html)).toBe(1);
expect(html).toContain('content="width=640"');
});

it("replaces the default viewport from inside nested arrays and fragments", () => {
ReactDOMServer.renderToString(
React.createElement(
Head,
null,
[
React.createElement(
React.Fragment,
{ key: "frag" },
React.createElement("meta", { name: "viewport", content: "width=1024" }),
),
],
React.createElement("meta", { name: "description", content: "kept" }),
),
);

const html = getSSRHeadHTML();
expect(countViewports(html)).toBe(1);
expect(html).toContain('content="width=1024"');
expect(html).toContain('<meta name="description" content="kept" data-next-head="" />');
});

it("does not let a user key='viewport' on another tag drop the default viewport", () => {
// In Next.js the default's raw key has no `$`, so it is never registered in
// the key set — only `name` dedupes it. A user tag that merely reuses the
// key "viewport" therefore leaves the default viewport in place.
ReactDOMServer.renderToString(
React.createElement(
Head,
null,
React.createElement("meta", { name: "description", content: "d", key: "viewport" }),
),
);

const html = getSSRHeadHTML();
expect(countViewports(html)).toBe(1);
expect(html).toContain(
'<meta name="viewport" content="width=device-width" data-next-head="" />',
);
expect(html).toContain('<meta name="description" content="d" data-next-head="" />');
});

it("replaces the default viewport with one from _document.getInitialProps", () => {
setDocumentInitialHead([
React.createElement("meta", {
name: "viewport",
content: "width=device-width, minimum-scale=1",
}),
]);

const html = getSSRHeadHTML();
expect(countViewports(html)).toBe(1);
expect(html).toContain('content="width=device-width, minimum-scale=1"');
});
});

// ─── _document.getInitialProps() head merge (issue #1569) ──────────────────
//
// Mirrors Next.js's test/e2e/next-head/index.test.ts assertion that head tags
Expand Down Expand Up @@ -1094,8 +1276,8 @@ describe("Head client sync (defaults + order, issue #1569 / #1677)", () => {

it("lets a user key='viewport' override the default viewport on the client", () => {
// Mirrors the SSR override test (head.test.ts) on the client path: the
// default viewport carries key="viewport", so a user tag with the same key
// wins the dedupe in reduceHeadChildren — same precedence as Next.js.
// user viewport wins the dedupe in reduceHeadChildren — same precedence
// as Next.js.
const { head, restore } = installFakeDocument();
try {
_clientHeadChildren.set(
Expand Down Expand Up @@ -1123,6 +1305,60 @@ describe("Head client sync (defaults + order, issue #1569 / #1677)", () => {
}
});

// Issue #4593: an unkeyed user viewport must replace the default on the
// client too, otherwise hydration re-inserts the default viewport the server
// already dropped.
it("keeps a single user viewport after hydration and across Head updates", () => {
const { head, createElement, restore } = installFakeDocument();
const viewportsOf = () =>
head.children.filter((el) => el.tagName === "meta" && el.getAttribute("name") === "viewport");
try {
// Server HTML for a page whose <Head> sets its own viewport.
const charset = createElement("meta");
charset.setAttribute("charset", "utf-8");
charset.setAttribute("data-next-head", "");
const userViewport = createElement("meta");
userViewport.setAttribute("name", "viewport");
userViewport.setAttribute("content", "width=device-width, initial-scale=1");
userViewport.setAttribute("data-next-head", "");
head.children.push(charset, userViewport);
for (const el of head.children) el.parentNode = head;

// Hydration: the page's <Head> mounts with the same viewport.
const pageHead = Symbol("page");
_clientHeadChildren.set(
pageHead,
React.createElement("meta", {
name: "viewport",
content: "width=device-width, initial-scale=1",
}),
);
_syncClientHead();

// The SSR node is reused untouched; no default viewport is added.
expect(head.children).toEqual([charset, userViewport]);

// The page updates its viewport (e.g. after a state change).
_clientHeadChildren.set(
pageHead,
React.createElement("meta", { name: "viewport", content: "width=500" }),
);
_syncClientHead();
expect(viewportsOf()).toHaveLength(1);
expect(viewportsOf()[0]?.getAttribute("content")).toBe("width=500");

// Navigating to a page without its own viewport restores the default.
_clientHeadChildren.delete(pageHead);
_clientHeadChildren.set(Symbol("next-page"), React.createElement("title", null, "Next"));
_syncClientHead();
expect(viewportsOf()).toHaveLength(1);
expect(viewportsOf()[0]?.getAttribute("content")).toBe("width=device-width");
expect(head.children.filter((el) => el.getAttribute("charset") === "utf-8")).toHaveLength(1);
} finally {
restore();
}
});

it("emits defaults even when the client <Head> has no children", () => {
const { head, restore } = installFakeDocument();
try {
Expand Down
Loading