Skip to content

fix(shims/head): match Next.js charset/viewport order and merge _document.getInitialProps head#1677

Open
james-elicx wants to merge 2 commits into
mainfrom
fix/issue-1569-head-charset-viewport-order
Open

fix(shims/head): match Next.js charset/viewport order and merge _document.getInitialProps head#1677
james-elicx wants to merge 2 commits into
mainfrom
fix/issue-1569-head-charset-viewport-order

Conversation

@james-elicx
Copy link
Copy Markdown
Member

Summary

Fixes #1569.

  • next/head shim now seeds defaultHead() (charset + viewport) into the SSR head collector before user tags, mirroring Next.js's defaultHead() in packages/next/src/shared/lib/head.tsx. They flow through the same dedupe pipeline so users can still override via key="charset" / key="viewport". Output now matches the exact-string assertion in Next.js's test/e2e/next-head/index.test.ts:

    <meta charset="utf-8" data-next-head=""><meta name="viewport" content="width=device-width" data-next-head=""><meta name="test-head-1" content="hello" data-next-head="">
    
  • Serialised attribute names follow HTML conventions (charSet -> charset, httpEquiv -> http-equiv, crossOrigin -> crossorigin, etc.). Previously these were emitted verbatim from the JSX prop name.

  • next/document shim no longer hardcodes charset / viewport in its <Head> — the defaults now flow through next/head's collector with data-next-head="". Fallback HTML shells in dev-server.ts and pages-page-response.ts likewise drop the duplicates.

  • New setDocumentInitialHead() accessor (plus ALS-backed state in head-state.ts) lets the SSR pipeline forward head tags returned by user _document.getInitialProps() into the same dedupe pipeline as next/head children. Both the dev server and the prod renderPagesPageResponse now invoke _document.getInitialProps() when present and pass the result through.

Test plan

  • pnpm test tests/head.test.ts
  • pnpm test tests/pages-page-response.test.ts
  • pnpm test tests/pages-router.test.ts
  • pnpm test tests/shims.test.ts
  • pnpm test tests/app-page-head.test.ts tests/script-head-ordering.test.ts
  • pnpm test tests/nextjs-compat/metadata.test.ts
  • pnpm test tests/static-export.test.ts -t "charset"
  • vp check (lint, format, types)

@james-elicx
Copy link
Copy Markdown
Member Author

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: fix(shims/head): match Next.js charset/viewport order and merge _document.getInitialProps head

Clean PR. The architecture is sound: seeding defaults via defaultHead() and routing _document.getInitialProps through the same dedupe pipeline is exactly the right approach. A few items worth addressing below.

_document.getInitialProps always fires for the default Document class

The guard at dev-server.ts:139 and pages-page-response.ts:345 checks typeof DocAny.getInitialProps === "function", but the default Document class in shims/document.tsx:112 always defines a static getInitialProps. This means even apps without a custom _document.tsx will call it every request, getting back { html: "" } and setting documentInitialHead to [].

This is functionally benign (empty array, no work in the dedupe pipeline), but it's unnecessary overhead: an await + try/catch + Array.isArray check on every render for apps that don't customize their document. Consider adding a check like DocAny.getInitialProps !== Document.getInitialProps (comparing against the base class's own method) to skip the call for the default implementation, or annotating the intent in a comment so future readers understand it's intentional.

Client-side _applyHeadPropsToElement doesn't use jsxAttrToHtml

The SSR path now correctly maps JSX attribute names to HTML (charSetcharset, etc.) via jsxAttrToHtml at line 324. But the client-side _applyHeadPropsToElement at line 397-408 still passes the raw JSX key to domEl.setAttribute(). For charSet, setAttribute("charSet", "utf-8") will produce <meta charset="utf-8"> in most browsers because setAttribute is case-insensitive for HTML elements — so this works by accident. But for httpEquiv, setAttribute("httpEquiv", "...") produces the attribute httpequiv (lowercase), not http-equiv (hyphenated). This would be a hydration mismatch if the SSR output has http-equiv but the client sets httpequiv. Not introduced by this PR (pre-existing), but worth noting since you've now fixed the SSR side.

Duplicated getInitialProps call logic

The getInitialProps invocation block is duplicated nearly identically between dev-server.ts:138-154 and pages-page-response.ts:343-360. Both construct the same minimal DocumentContext, do the same try/catch, extract the same initialHead array. This is the kind of runtime behavior that AGENTS.md says should live in a helper under server/* rather than being inlined in two places. A small helper like callDocumentGetInitialProps(DocCtor, setDocumentInitialHead) would eliminate the duplication and make it testable in isolation.

Minor: defaultHead() allocates on every call

defaultHead() creates two fresh React.createElement calls on every invocation of getSSRHeadHTML(). Since these are always the same elements, consider hoisting them to module-level constants. Negligible perf impact in practice, but it's a free improvement.

Test quality

The new tests are thorough and well-structured. Good coverage of:

  • Default ordering (charset → viewport → user)
  • data-next-head attribute presence
  • charSet meta-type dedupe override
  • Key-based viewport override
  • setDocumentInitialHead merge and reset isolation

The existing test adjustments (expect(html).toBe("")expect(html).not.toContain(...)) are correct — defaults are now always present so empty-string checks no longer apply.

Overall this is solid work that brings vinext closer to Next.js parity on a user-visible issue. The architecture of flowing defaults and _document initial props through the same dedupe pipeline is the right design.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 28, 2026

Review posted on PR #1677. Here's a summary of the key findings:

Architecture: Sound design — seeding defaultHead() (charset + viewport) into the SSR head collector and routing _document.getInitialProps through the same dedupe pipeline is the correct approach, matching Next.js's internal structure.

Items raised:

  1. getInitialProps always fires — The default Document class always has getInitialProps, so the guard typeof DocAny.getInitialProps === "function" is always true, causing an unnecessary async call on every render even for apps without a custom _document.

  2. Client-side _applyHeadPropsToElement doesn't use jsxAttrToHtml — SSR now correctly maps charSetcharset, httpEquivhttp-equiv, but the client-side DOM path still passes raw JSX names to setAttribute(). This is a pre-existing issue but creates a potential hydration mismatch now that SSR is fixed.

  3. Duplicated getInitialProps call logic — The invocation block is nearly identical between dev-server.ts and pages-page-response.ts. Per AGENTS.md guidelines, this should be extracted into a shared helper under server/*.

  4. Minor: defaultHead() allocates on every call — Could hoist the constant elements to module scope.

  5. Tests are strong — Good coverage of ordering, dedupe overrides, data-next-head attributes, and isolation between renders.

github run

@james-elicx
Copy link
Copy Markdown
Member Author

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review: fix(shims/head): match Next.js charset/viewport order and merge _document.getInitialProps head

All three items from the first review have been addressed cleanly in the follow-up commit:

Previously raised — now resolved

  1. getInitialProps always fires — Fixed. The new callDocumentGetInitialProps helper captures the base Document.getInitialProps reference at module scope and short-circuits when the resolved method is the same reference. Apps without a custom _document skip the async call entirely. The indexed-access pattern (Document["getInitialProps"]) to avoid oxlint's unbound-method rule is a nice touch.

  2. Client-side _applyHeadPropsToElement hydration mismatch — Fixed. Both the boolean and string attribute branches now go through jsxAttrToHtml(), so httpEquiv correctly produces http-equiv on the DOM side matching SSR output. New tests at head.test.ts:542-563 verify both httpEquiv and charSet mapping.

  3. Duplicated getInitialProps call logic — Fixed. Extracted to server/document-initial-head.ts. Both dev-server.ts and pages-page-response.ts now call the shared helper with identical signatures. The module-level doc comment explains the contract clearly.

Remaining observation (non-blocking)

No unit tests for callDocumentGetInitialProps itself. The helper is exercised indirectly through integration tests (pages-router.test.ts, pages-page-response.test.ts), and the logic is straightforward enough that this isn't blocking. But the doc comment says "lets the (mostly-static) implementation be tested in isolation" — a focused test that verifies the skip-for-default-method behavior and the error-logging path would complete that promise. Could be a follow-up.

defaultHead() still allocates on every call — the minor suggestion to hoist the constant elements to module scope wasn't addressed. Not a real problem; React.createElement is cheap and this runs once per request. Mentioning for completeness.

Ship it.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 28, 2026

Review posted on PR #1677approved. Here's a summary:

All three items from the first review were addressed in the follow-up commit:

  1. getInitialProps always fires — Fixed via reference comparison against DEFAULT_GET_INITIAL_PROPS in the new shared helper, skipping the call for apps without a custom _document.

  2. Client-side hydration mismatch — Fixed by applying jsxAttrToHtml() to both attribute branches in _applyHeadPropsToElement, with new test coverage for httpEquiv and charSet.

  3. Duplicated call logic — Extracted to server/document-initial-head.ts with both call sites now using the shared helper.

Two non-blocking observations noted: no direct unit tests for the new helper (exercised indirectly), and defaultHead() still allocates per call (negligible).

github run

…ment.getInitialProps head (#1569)

Pages Router head emission did not match Next.js's canonical order
(`<meta charset>` then `<meta viewport>` then user tags, all with
`data-next-head=""`) and ignored head tags returned from a user
`_document.getInitialProps()`.

- `next/head` shim now seeds `defaultHead()` (charset + viewport) into
  the SSR collector before user tags, mirroring `defaultHead()` in
  `next/src/shared/lib/head.tsx`. They flow through the same dedupe
  pipeline so users can still override via `key="charset"` /
  `key="viewport"`.
- Serialised attribute names follow HTML conventions
  (`charSet` -> `charset`, `httpEquiv` -> `http-equiv`, etc.), matching
  Next.js's `test/e2e/next-head/index.test.ts` exact-string assertion.
- `next/document` shim no longer hardcodes charset/viewport in `<Head>`
  (those defaults now flow from `next/head`); fallback HTML shells in
  `dev-server.ts` and `pages-page-response.ts` likewise drop the
  duplicates.
- New `setDocumentInitialHead()` accessor and ALS-backed state lets the
  SSR pipeline forward head tags returned by user
  `_document.getInitialProps()` into the same dedupe pipeline as
  `next/head` children. Both the dev server and the prod
  `renderPagesPageResponse` invoke `_document.getInitialProps()` when
  present and pass the result through.

Adds focused regression coverage in `tests/head.test.ts` for ordering,
default attribute serialisation, key-based overrides, and the
`_document.getInitialProps()` merge path.

Refs #1569.
- Extract shared `callDocumentGetInitialProps` helper under
  `server/document-initial-head.ts` so both `dev-server.ts` and
  `pages-page-response.ts` go through the same code path. The previous
  duplicated try/catch blocks drifted independently and were the kind of
  runtime-behavior that AGENTS.md says should live in a helper.

- Skip the call when `DocumentComponent.getInitialProps` is still the
  unmodified default inherited from vinext's `next/document` shim.
  Comparing against the captured base reference (`DEFAULT_GET_INITIAL_PROPS`)
  distinguishes a user override from the default — extending `Document`
  without overriding the method inherits the same static function, so
  invoking it on every render is wasted work.

- Use `jsxAttrToHtml` on the client-side `_applyHeadPropsToElement` path
  so `httpEquiv` is set as `http-equiv` rather than `httpequiv`
  (lowercased by `setAttribute`). Matches the SSR output and prevents a
  hydration mismatch when SSR emits the hyphenated form.

- Update stale `tests/document.test.ts` Head assertions: the shim's
  `<Head>` no longer hardcodes charset/viewport — those flow through
  `next/head`'s `defaultHead()` so the dedupe pipeline handles them.
@james-elicx james-elicx force-pushed the fix/issue-1569-head-charset-viewport-order branch from 20679f8 to 6d40262 Compare May 28, 2026 17:36
@james-elicx
Copy link
Copy Markdown
Member Author

Rebased on origin/main to resolve merge conflicts. Conflicts were in packages/vinext/src/server/dev-server.ts and packages/vinext/src/server/pages-page-response.ts — kept both branches' additions (the new clientTraceMetadata OpenTelemetry head injection from #1644 plus this PR's setDocumentInitialHead plumbing). pnpm run check passes locally.

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 28, 2026

Open in StackBlitz

npm i https://pkg.pr.new/vinext@1677

commit: 6d40262

@github-actions
Copy link
Copy Markdown

Example Preview Production Original
app-router-cloudflare preview production
pages-router-cloudflare preview production
app-router-playground preview production original
realworld-api-rest preview production
nextra-docs-template preview production
benchmarks preview production
hackernews preview production original
web preview production

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 next/head: charset/viewport ordering and _document.getInitialProps head-tag merge order not matched

1 participant