Skip to content

fix(pages): prevent duplicate viewport meta tags in next/head - #4595

Open
itsrayeedscodespace wants to merge 1 commit into
cloudflare:mainfrom
itsrayeedscodespace:fix/4593-pages-viewport-dedup
Open

itsrayeedscodespace wants to merge 1 commit into
cloudflare:mainfrom
itsrayeedscodespace:fix/4593-pages-viewport-dedup

Conversation

@itsrayeedscodespace

Copy link
Copy Markdown

Fixes #4593

Summary

What was broken: In a Pages Router app, <Head><meta name="viewport" content="width=device-width, initial-scale=1" /></Head> rendered two viewport tags: vinext's default (width=device-width) and the app's. Next.js renders only the app's.

Root cause: reduceHeadChildren (packages/vinext/src/shims/head.ts) got [...defaultHead(), ...userChildren] and passed each one through Children.toArray. That changed the default viewport's key from viewport to .$viewport. The dedupe filter treats any key with a $ as user-keyed, and for keyed meta[name] tags it skips the name-category check. So the default survived next to an unkeyed user viewport. The same thing happened on the client in _syncClientHead.

Why the fix matches Next.js: Next's reduceComponents() flattens only the user elements (in side-effect.tsx, via Children.toArray), then runs .reverse().concat(defaultHead().reverse()).filter(unique()).reverse(). The defaults keep their raw keys, so unique() never registers them as keyed and dedupes them by name / charSet.

Changes

  • reduceHeadChildren(headChildren, defaults = []) takes the defaults as a separate argument. They are placed ahead of the user tags but are not passed through Children.toArray. The order ([defaults, ...user] → reverse → filter → reverse) is the same as Next's.
  • getSSRHeadHTML() and _syncClientHead() pass defaultHead() through that argument. Nothing else changes: user children (including _document.getInitialProps head) are still flattened per entry exactly as before.

Tests

tests/head.test.ts, new describe("Head user viewport replaces the default (issue #4593)"):

  • no user viewport → exactly one default viewport
  • unkeyed user viewport → exact HTML: default charset + the user's viewport only
  • port of Next's head-priority fixture (user charset/viewport/title, exact order)
  • port of Next's head-duplicate-default-keys fixture (several <Head>s, keyed charset/viewport)
  • last unkeyed viewport wins across several <Head> instances
  • user viewport inside a nested array and fragment, while an unrelated meta is kept
  • a user key="viewport" on an unrelated tag no longer removes the default viewport (Next.js behavior: the default's raw key is never added to the key set)
  • viewport from _document.getInitialProps replaces the default
  • client sync: hydration reuses the SSR user viewport node and adds no default; a Head update keeps one viewport; unmounting the page's Head restores the default

Integration:

  • tests/fixtures/pages-basic/pages/head-custom-viewport.tsx (new small fixture page)
  • tests/pages-router.test.ts: SSR HTML has exactly one viewport, the user's (ported from Next's rendering-head.test.ts "header helper dedupes tags")
  • tests/e2e/pages-router/head-and-dynamic.spec.ts: in a real browser, one viewport after hydration, the default after client navigation to /about, and the custom one again after back navigation

Next.js references: test/development/pages-dir/client-navigation/rendering-head.test.ts and its fixtures head-priority.js and head-duplicate-default-keys.js.

Validation

Red/green (run locally):

  • Unmodified source: vp test run tests/head.test.ts → 7 failed / 60 passed. Example: Received: "<meta charset…/><meta name="viewport" content="width=device-width" …/><meta name="viewport" content="width=device-width, initial-scale=1" …/>"
  • Unmodified source: the new pages-router.test.ts case → expected [ Array(2) ] to have a length of 1 but got 2
  • Unmodified source: the new Playwright test → toHaveCount: Expected: 1, Received: 2
  • With the fix, all of the above pass.

Commands with the fix applied:

  • vp test run tests/head.test.ts tests/shims.test.ts tests/document.test.ts → 1563 passed
  • vp test run tests/head.test.ts tests/shims.test.ts tests/document.test.ts tests/pages-router.test.ts tests/pages-router-concurrency.test.ts tests/pages-preview.test.ts tests/pages-page-response.test.ts tests/pages-page-handler.test.ts tests/unified-request-context.test.ts tests/client-page-root.test.ts → 2278 passed, 1 failed. The failure is pages-router.test.ts > skips data cache reads while res.revalidate() regenerates a page in dev. It fails the same way on unmodified main in my environment, passes when run alone, and is unrelated to head handling.
  • PLAYWRIGHT_PROJECT=pages-router playwright test tests/e2e/pages-router/head-and-dynamic.spec.ts --project pages-router → 10 passed
  • vp fmt --check clean; node scripts/sync-next-types.mjs --check and node scripts/check-shim-types.mjs pass
  • vp check: no findings in changed files. Locally it reports type errors only in apps/web and packages/cloudflare (missing generated Workers types such as Env and cloudflare:workers), because of my local install.
  • git diff --check clean

Environment limitations: Node 26 locally, where better-sqlite3 (used only by the ecosystem/better-auth fixture) doesn't compile. So I installed with pnpm install --frozen-lockfile --ignore-scripts. I relied on CI for the full Vitest suite and the other Playwright projects.

Compatibility

  • Default viewport: still emitted when the app sets none, on SSR and after client sync.
  • Charset: unchanged. charSet dedupe doesn't depend on keys, so a user charset still replaces the default, and the default is still emitted otherwise.
  • React key scoping (Fix next/head key deduplication #518): user children are still flattened per <Head> instance with Children.toArray, so keyed dedupe across instances is unchanged (existing tests pass).
  • Client defaults (fix(shims/head): keep charset/viewport defaults on client hydration #1713): _syncClientHead still seeds defaultHead(), so hydration keeps the server's charset/viewport (existing tests pass).
  • Behavior change, matching Next.js: a user tag that reuses key="charset"/key="viewport" on an unrelated tag (e.g. <meta name="description" key="viewport">) no longer drops the default. <meta name="viewport" key="viewport"> still replaces it.
  • No App Router code paths are touched.

🤖 Generated with Claude Code

reduceHeadChildren flattened defaultHead() through Children.toArray along
with the user's <Head> children, turning the default viewport's key into
".$viewport". The dedupe filter then treated it as keyed, skipped the
meta[name] check, and rendered it next to a user <meta name="viewport">.

Like Next.js's reduceComponents(), only the user children are flattened
now and defaultHead() is concatenated afterwards with its raw keys, so a
user viewport replaces the default on the server and on the client.

Fixes cloudflare#4593

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@clanky-the-clanker

Copy link
Copy Markdown

This pull request was not reviewed automatically because its author does not have write access to this repository. A maintainer with write access can start a review by commenting clanky review (or clanker review or @clanky-the-clanker review), optionally followed by instructions (e.g. clanky review focus on the retry logic).

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.

Pages Router renders its default viewport tag next to the app's

1 participant