diff --git a/packages/vinext/src/shims/head.ts b/packages/vinext/src/shims/head.ts index 6dde60cb9b..cfc451c9cc 100644 --- a/packages/vinext/src/shims/head.ts +++ b/packages/vinext/src/shims/head.ts @@ -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)) .filter(Boolean) .join(""); @@ -251,16 +247,28 @@ function createUniqueHeadFilter(): (child: React.ReactElement) => boolean { }; } -export function reduceHeadChildren(headChildren: React.ReactNode[]): React.ReactElement[] { - return headChildren +/** + * Reduce `` 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 `` replaces the default instead of rendering + * next to it. + */ +export function reduceHeadChildren( + headChildren: React.ReactNode[], + defaults: React.ReactElement[] = [], +): React.ReactElement[] { + const userElements = headChildren .reduce( (flattenedChildren, child) => flattenedChildren.concat(Children.toArray(child)), [], ) - .reduce(collectHeadElements, []) - .reverse() - .filter(createUniqueHeadFilter()) - .reverse(); + .reduce(collectHeadElements, []); + return [...defaults, ...userElements].reverse().filter(createUniqueHeadFilter()).reverse(); } /** @@ -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 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 `` or `` + * (keyed or not) replaces the matching default through the dedupe pipeline. * * @internal — exported for unit tests; called from the Head client effect. */ @@ -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 = diff --git a/tests/e2e/pages-router/head-and-dynamic.spec.ts b/tests/e2e/pages-router/head-and-dynamic.spec.ts index 01fd002cec..d934f9bf19 100644 --- a/tests/e2e/pages-router/head-and-dynamic.spec.ts +++ b/tests/e2e/pages-router/head-and-dynamic.spec.ts @@ -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", () => { diff --git a/tests/fixtures/pages-basic/pages/head-custom-viewport.tsx b/tests/fixtures/pages-basic/pages/head-custom-viewport.tsx new file mode 100644 index 0000000000..c5050cd876 --- /dev/null +++ b/tests/fixtures/pages-basic/pages/head-custom-viewport.tsx @@ -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 ( +
+ + Custom viewport - vinext + + +

Custom viewport

+ Go to About +
+ ); +} diff --git a/tests/head.test.ts b/tests/head.test.ts index d031cb50bc..c2d7ea84bc 100644 --- a/tests/head.test.ts +++ b/tests/head.test.ts @@ -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, @@ -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 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 `` 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( + '', + ); + }); + + 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( + '' + + '', + ); + }); + + // 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 ") + 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( + '' + + '' + + '', + ); + }); + + // 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(''); + expect(html).toContain(''); + }); + + 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(''); + }); + + 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( + '', + ); + expect(html).toContain(''); + }); + + 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 @@ -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( @@ -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 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 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 has no children", () => { const { head, restore } = installFakeDocument(); try { diff --git a/tests/pages-router.test.ts b/tests/pages-router.test.ts index ab0c6e8886..ef91f670a6 100644 --- a/tests/pages-router.test.ts +++ b/tests/pages-router.test.ts @@ -1812,6 +1812,20 @@ export default function Page({ value }: { value: string }) { ); }); + // Issue #4593. Ported from Next.js: + // test/development/pages-dir/client-navigation/rendering-head.test.ts ("header helper dedupes tags") + // https://github.com/vercel/next.js/blob/canary/test/development/pages-dir/client-navigation/rendering-head.test.ts + it("renders a next/head viewport in place of the default viewport", async () => { + const html = await (await fetch(`${baseUrl}/head-custom-viewport`)).text(); + const headContents = html.match(/]*>([\s\S]*?)<\/head>/i)?.[1] ?? ""; + expect(headContents.match(/name="viewport"/g)).toHaveLength(1); + expect(headContents).toContain( + '', + ); + expect(headContents).not.toContain('content="width=device-width" '); + expect(headContents.trimStart()).toMatch(/^ { const firstRes = await fetch(`${baseUrl}/isr-second-render-state`); expect(firstRes.status).toBe(200);