Skip to content

fix(pages): keep charset first in custom document heads - #2909

Merged
james-elicx merged 9 commits into
cloudflare:mainfrom
ting-hong-shieh:fix/pages-head-order
Aug 14, 2026
Merged

fix(pages): keep charset first in custom document heads#2909
james-elicx merged 9 commits into
cloudflare:mainfrom
ting-hong-shieh:fix/pages-head-order

Conversation

@ting-hong-shieh

Copy link
Copy Markdown
Contributor

Summary

  • place the collected next/head block immediately after the opening <head> tag in production Pages responses, ahead of custom Document children
  • keep the block inside Vite's HTML transform pipeline in development, then move the transformed result ahead of Vite's prepended client scripts
  • cover the canonical charset, viewport, and user-tag order in response-unit, development-server, and production-build tests

This fixes the remaining failure reported after #1677: custom Document children and development scripts could still appear before <meta charset="utf-8"> even though the head collector returned the tags in the correct order.

Testing

  • pnpm exec vp check packages/vinext/src/server/dev-server.ts packages/vinext/src/server/pages-page-response.ts tests/pages-page-response.test.ts tests/pages-router.test.ts
  • pnpm test tests/head.test.ts tests/pages-page-response.test.ts
  • pnpm test tests/pages-router.test.ts -t "renders next/head tags in SSR HTML <head>"
  • pnpm test tests/pages-router.test.ts -t "serves pages from production build end-to-end"

Closes #1569

@ting-hong-shieh
ting-hong-shieh marked this pull request as ready for review August 13, 2026 12:45
@pkg-pr-new

pkg-pr-new Bot commented Aug 14, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@vinext/cloudflare@2909
npm i https://pkg.pr.new/create-vinext-app@2909
npm i https://pkg.pr.new/@vinext/types@2909
npm i https://pkg.pr.new/vinext@2909

commit: 390ec68

@github-actions

github-actions Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared 390ec68 against base a3d1d1d using alternating same-runner rounds. Next.js was unchanged and skipped.

1 improved · 0 regressed · 5 within ±1.5%

Scenario Framework Baseline Current Change
Client bundle size (gzip) vinext 135.4 KB 135.4 KB ⚫ +0.0%
Client entry size (gzip) vinext 122.8 KB 122.8 KB ⚫ +0.0%
Dev server cold start vinext 3.11 s 3.04 s 🟢 -2.2%
Production build time vinext 3.14 s 3.14 s ⚫ +0.1%
RSC entry closure size (gzip) vinext 114.6 KB 114.6 KB ⚫ +0.0%
Server bundle size (gzip) vinext 194.8 KB 194.8 KB ⚫ +0.0%

View detailed results and traces

🟢 improvement · 🔴 regression · ⚫ change below 1.5% · paired base/head

@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

@ask-bonk

ask-bonk Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

UnknownError: ProviderInitError

github run

@ask-bonk

ask-bonk Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

@james-elicx Bonk workflow failed. Check the logs for details.

View workflow run · To retry, trigger Bonk again.

@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

@ask-bonk

ask-bonk Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

UnknownError: ProviderInitError

github run

@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

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.

Reviewed the charset-ordering fix. The approach is sound and matches Next.js behavior:

  • Prod (pages-page-response.ts): inserting ssrHeadHTML right after the opening <head> (via a function replacer) is the right call — using a function replacer avoids $&/$1 special-pattern injection from user head HTML, and the /<head(?:\s[^>]*)?>/i regex correctly avoids matching <header> and handles attributed <head data-theme="...">.
  • Dev (dev-server.ts): wrapping the collected head in SSR_HEAD_START/END markers so it survives Vite's HTML transform and then hoisting it ahead of Vite's prepended client scripts is a clean solution to the dev-vs-prod parity problem.
  • Test coverage is good: charset/viewport ordering is asserted on the dev path (renders next/head tags in SSR HTML <head> runs against createServer), the prod build path, and the document-child ordering is covered in both dev and prod via the it.each(["dev", "prod"]) document tests. The new unit tests in pages-page-response.test.ts directly cover the custom-Document-children and no-Document font-ordering cases.

A few non-blocking observations below. No blocking issues found — dev/prod parity is maintained and the ordering matches the Next.js test/e2e/next-head expectations cited in the tests.

Comment thread packages/vinext/src/server/pages-page-response.ts
Comment thread packages/vinext/src/server/dev-server.ts
@ask-bonk

ask-bonk Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Review posted successfully. Here's my summary:

Review of PR #2909fix(pages): keep charset first in custom document heads

Verdict: No blocking issues. The fix is well-designed and correctly maintains dev/prod parity.

What the PR does correctly

  • Prod (pages-page-response.ts): Inserts collected next/head HTML immediately after the opening <head> tag using a function replacer — this avoids $&/$1 special-replacement-pattern injection from user-supplied head HTML, and the regex /<head(?:\s[^>]*)?>/i correctly handles <head data-theme="dark"> while not matching <header>.
  • Dev (dev-server.ts): Wraps the collected head in SSR_HEAD_START/END comment markers so it survives Vite's transformIndexHtml, then hoists the transformed block ahead of Vite's prepended dev client scripts. Clean solution to the ordering problem.
  • documentStylesHTML / tailHeadHTML placement is consistent between dev and prod (styles at the tail of <head>).

Test coverage

Solid — the dev path is genuinely exercised (startFixtureServer uses createServer, i.e. dev), the prod build path is covered end-to-end, and document-child ordering is verified in both dev and prod via the existing it.each(["dev","prod"]) document tests.

Non-blocking observations posted inline

  1. pages-page-response.ts:344 — If a custom Document renders no <head> at all, the .replace silently drops ssrHeadHTML. Pre-existing edge case for malformed Documents, not a regression.
  2. dev-server.ts:500 — Suggested adding an explicit page-head-vs-document-child assertion to the dev/prod document test to mirror the prod-only unit test, so the dev hoist path is directly asserted for user next/head tags.

github run

@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

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.

Reviewed the charset-ordering fix. The approach is sound and dev/prod parity is well maintained. Verified:

  • Prod custom-Document (pages-page-response.ts): two-step replace — ssrHeadHTML inserted after the opening <head> (function replacer avoids $&/$1 special-pattern injection from user head HTML), then font/asset/tail tags before </head>. The /<head(?:\s[^>]*)?>/i regex correctly handles <head data-theme="..."> and does not match <header>.
  • Dev (dev-server.ts): wrapping the collected head in SSR_HEAD_START/END markers so it survives transformIndexHtml, then hoisting ahead of Vite's prepended client scripts, is a clean solution. The no-op empty-headHTML case is harmless.
  • Ordering parity between the four head-injection sites (dev/prod × Document/no-Document) is consistent: charset-bearing ssrHeadHTML/headHTML first, then font, then generated assets, then tail/styles.
  • Test coverage is thorough: the dev path is genuinely exercised (renders next/head tags in SSR HTML <head> runs against startFixtureServer, i.e. dev), the prod build path is covered end-to-end, and document-child ordering is verified in both dev and prod.

No blocking issues. Two non-blocking observations inline.

Comment thread packages/vinext/src/server/pages-page-response.ts
Comment thread packages/vinext/src/server/pages-page-response.ts
@ask-bonk

ask-bonk Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Review posted successfully. Here's my summary:

Review of PR #2909fix(pages): keep charset first in custom document heads

Verdict: No blocking issues. The fix is well-designed and correctly maintains dev/prod parity.

What the PR does correctly

  • Prod custom-Document (pages-page-response.ts): Two-step replace — inserts ssrHeadHTML after the opening <head> via a function replacer (avoids $&/$1 special-pattern injection from user head HTML), then font/asset/tail tags before </head>. The /<head(?:\s[^>]*)?>/i regex handles <head data-theme="..."> and won't match <header>.
  • Dev (dev-server.ts): Wrapping the collected head in SSR_HEAD_START/END markers so it survives transformIndexHtml, then hoisting the block ahead of Vite's prepended dev client scripts. Clean solution.
  • Ordering parity across all four injection sites (dev/prod × Document/no-Document) is consistent: charset-bearing head first, then fonts, then generated assets, then tail/styles.

Test coverage

Solid — the dev path is genuinely exercised (the <head> ordering test runs against startFixtureServer, i.e. dev), the prod build path is covered end-to-end, and document-child ordering is verified in both dev and prod.

Non-blocking observations posted inline

  1. pages-page-response.ts:617traceMetaHTML and document stylesHTML moved from top of <head> into tailHeadHTML (end of head). For styles this matches Next.js; the client-trace-metadata <meta> shift from top to bottom is a side effect worth confirming.
  2. pages-page-response.ts:344 — Pre-existing edge case: a custom Document rendering no <head> at all would silently drop ssrHeadHTML. Not a regression, but a fallback could be considered.

github run

@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

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.

Reviewed the charset-ordering fix end-to-end. The approach is sound and dev/prod parity is well maintained. I confirm the earlier findings and have nothing blocking to add.

Verified correct:

  • Prod (pages-page-response.ts): two-step replace inserts ssrHeadHTML right after the opening <head> via a function replacer (avoids $&/$1 special-pattern injection from user head HTML), then fonts/assets/tailHeadHTML before </head>. The /<head(?:\s[^>]*)?>/i regex correctly matches <head data-theme="dark"> and does not match <header> (after <head it requires \s or >, and <header> has er>). Since __NEXT_MAIN__ is replaced with the body marker before this runs, the only <head> in the string is the document's, so no risk of matching a <head> in rendered body content.
  • Dev (dev-server.ts): wrapping the collected head in SSR_HEAD_START/END comment markers so it survives transformIndexHtml, then hoisting the block ahead of Vite's prepended dev client scripts and custom Document children, is a clean solution. Vite injects its HMR client near the top of <head> (before the START marker, which sits at the old </head> position), so the move correctly places the collected head first.
  • head.ts shim: splitting the single [children] effect into a mount/unmount cleanup ([]) plus a set-only update effect ([children]) is the right fix — the old combined effect ran cleanup on every children change, which removed and re-appended even unchanged tags to the end of <head> on every Fast Refresh. The full-projection reconcile in _syncClientHead means dropping cleanup from the update path is safe.
  • Test coverage: genuinely exercises dev (renders next/head tags in SSR HTML <head> runs against a dev server), the prod build path end-to-end, custom-Document-children ordering in both dev and prod, and the new e2e after-document HMR assertion directly covers the appended-changed-tag behavior of the shim change.

Two non-blocking observations below (both previously noted).

Comment thread packages/vinext/src/server/pages-page-response.ts
Comment thread packages/vinext/src/server/pages-page-response.ts
@ask-bonk

ask-bonk Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Review posted successfully to PR #2909.

Summary

Verdict: No blocking issues. The fix is well-designed and dev/prod parity is maintained. I independently verified the correctness of all three moving parts:

What the PR does correctly:

  • Prod (pages-page-response.ts) — Two-step replace: inserts ssrHeadHTML right after the opening <head> via a function replacer (safe against $&/$1 injection from user head HTML), then fonts/assets/tailHeadHTML before </head>. The /<head(?:\s[^>]*)?>/i regex handles <head data-theme="dark"> and won't match <header>. Because __NEXT_MAIN__ → body marker happens first, there's no risk of matching a <head> in rendered body content.
  • Dev (dev-server.ts) — Wrapping the collected head in SSR_HEAD_START/END comment markers so it survives transformIndexHtml, then hoisting it ahead of Vite's prepended HMR client and custom Document children. Clean, correct solution.
  • head.ts shim — Splitting the combined [children] effect into a mount/unmount cleanup ([]) + set-only update ([children]) is the right fix: the old combined effect ran cleanup on every render, re-appending even unchanged tags to the end of <head> on every Fast Refresh. The full reconcile in _syncClientHead makes dropping cleanup from the update path safe.
  • Tests — Genuinely exercise dev, prod build end-to-end, document-child ordering in both modes, plus the new e2e after-document HMR assertion.

Two non-blocking observations posted inline (both consistent with prior bonk reviews):

  1. pages-page-response.ts:344 — pre-existing edge case where a custom Document with no <head> silently drops ssrHeadHTML; suggested an optional </head> fallback.
  2. pages-page-response.ts:611traceMetaHTML shifts from top to tail of <head>; arguably closer to Next.js but worth a one-line test to lock the ordering in.

github run

@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

@ask-bonk

ask-bonk Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

UnknownError: ProviderInitError

github run

@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

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.

Reviewed the charset-ordering fix. The core approach is sound and I confirm the previously-noted points: the prod function-replacer insert after <head> avoids $&/$1 injection, the /<head(?:\s[^>]*)?>/i regex won't match <header> and handles attributed heads, and — importantly — in both dev and prod the head regex runs after __NEXT_MAIN__ is replaced with the body/stream marker, so the body content isn't in the string yet and there's no risk of matching a <head> inside rendered body. The head.ts effect split (mount-only cleanup + set-only update) is the right fix for the Fast Refresh re-append behavior.

One dev/prod parity divergence worth calling out that the prior reviews missed, plus the pre-existing malformed-Document edge case. Both non-blocking.

Comment thread packages/vinext/src/server/pages-page-response.ts
Comment thread packages/vinext/src/server/pages-page-response.ts
@ask-bonk

ask-bonk Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Review posted successfully to PR #2909.

Summary

Verdict: No blocking issues. The charset-ordering fix is well-designed and I verified the correctness of all three moving parts.

Verified correct:

  • Prod (pages-page-response.ts) — Function-replacer insert after the opening <head> avoids $&/$1 special-pattern injection from user head HTML; the /<head(?:\s[^>]*)?>/i regex handles <head data-theme="..."> and won't match <header>. Critically, in both dev and prod the head regex runs after __NEXT_MAIN__ is replaced with the body/stream marker, so no rendered body content is in the string — no risk of matching a stray <head> in body.
  • Dev (dev-server.ts) — The SSR_HEAD_START/END marker + hoist approach cleanly survives transformIndexHtml and reorders ahead of Vite's prepended dev scripts.
  • head.ts shim — Splitting the combined effect into mount-only cleanup ([]) + set-only update ([children]) correctly fixes the Fast Refresh re-append bug. Effect ordering on mount is fine.

One new finding the prior bonk reviews missed (posted inline, non-blocking):

  • Dev/prod parity divergence for clientTraceMetadata — This PR moves traceMetaHTML into tailHeadHTML (end of <head>) in prod, but dev's getHeadHTML() (dev-server.ts:1677) still appends trace HTML to the collected head that gets hoisted to the top of <head>. So the same feature places trace <meta> tags at the head-top in dev and head-tail in prod. Only affects apps using experimental.clientTraceMetadata, hence non-blocking, but per AGENTS.md parity guidance it should be aligned with a test.

Plus the pre-existing edge case (pages-page-response.ts:344) where a malformed Document with no <head> would silently drop ssrHeadHTML — not a regression, optional defense-in-depth.

github run

@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

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.

Reviewed the charset-ordering fix end-to-end. The approach is sound and I ran the targeted suites locally — tests/pages-page-response.test.ts (56 passed), tests/head.test.ts (53 passed), and both new pages-router.test.ts cases (renders next/head tags in SSR HTML <head>, places client trace metadata after custom Document children in dev and prod) all pass.

Verified correct:

  • Prod (pages-page-response.ts) — Two-step replace: ssrHeadHTML inserted after the opening <head> via a function replacer (safe against $&/$1 special-pattern injection from user head HTML), then fonts/assets/tailHeadHTML before </head>. The /<head(?:\s[^>]*)?>/i regex handles <head data-theme="dark"> and won't match <header>. Because __NEXT_MAIN__ → body marker runs at line 335 before the head regex, no rendered body content is in the string — no risk of matching a stray <head> in body.
  • Dev (dev-server.ts) — The SSR_HEAD_START/END marker + hoist approach cleanly survives transformIndexHtml and reorders the collected head ahead of Vite's prepended dev scripts and custom Document children. The head regex runs on the pre-split shell (still holding STREAM_BODY_MARKER), so no body content is present.
  • head.ts shim — Splitting the combined [children] effect into a mount-only cleanup ([]) + set-only update ([children]) is the right fix: the old combined effect ran cleanup on every render, re-appending even unchanged tags to the end of <head> on every Fast Refresh. The full isEqualNode reconcile in _syncClientHead makes dropping cleanup from the update path safe.
  • dev/prod trace-metadata parity — This PR actually fixes the divergence flagged in earlier reviews: dev now routes trace HTML through getTailHeadHTML, so trace <meta> lands at the head tail in both dev and prod. The new dev+prod parity test locks this in. 👍

No blocking issues. Two non-blocking observations below.

Comment thread packages/vinext/src/server/dev-server.ts
Comment thread packages/vinext/src/server/pages-page-response.ts
@ask-bonk

ask-bonk Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Review posted successfully to PR #2909.

Summary

Verdict: No blocking issues. The charset-ordering fix is well-designed, and I verified all four moving parts and ran the targeted suites locally (all pass).

Verified correct:

  • Prod (pages-page-response.ts) — Function-replacer insert after the opening <head> avoids $&/$1 injection; the /<head(?:\s[^>]*)?>/i regex handles attributed heads and won't match <header>. The head regex runs after __NEXT_MAIN__ → body marker, so no body content is in scope.
  • Dev (dev-server.ts)SSR_HEAD_START/END marker + hoist survives transformIndexHtml and reorders the collected head ahead of Vite's prepended dev scripts.
  • head.ts shim — Splitting the combined effect into mount-only cleanup ([]) + set-only update ([children]) correctly fixes the Fast Refresh re-append bug; the isEqualNode reconcile in _syncClientHead makes it safe.
  • dev/prod trace-metadata parity — This PR actually fixes the divergence flagged in prior bonk reviews: dev now routes trace HTML through getTailHeadHTML, so it lands at the head tail in both modes, and the new parity test locks it in.

Two non-blocking observations posted inline:

  1. dev-server.ts:505 — the START/END markers are sliced out unconditionally before the re-insert regex; if the regex ever misses, the collected head (incl. charset) is silently dropped. Dev analogue of the pre-existing prod edge case at line 344. A fallback append would make the charset unlosable.
  2. pages-page-response.ts:348 — the reorg introduces a font-ordering asymmetry (tail in the Document path, top in the no-Document path). Harmless since font position is non-observable, but a companion assertion/comment would document the intent.

Tests run locally: tests/pages-page-response.test.ts (56/56), tests/head.test.ts (53/53), and both new pages-router.test.ts cases pass.

github run

@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

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.

Reviewed the charset-ordering fix end-to-end. The approach is sound, dev/prod parity is well maintained, and I ran tests/head.test.ts (57) and tests/pages-page-response.test.ts (56) locally — all pass.

Verified correct:

  • Prod (pages-page-response.ts) — Function-replacer insert after the opening <head> avoids $&/$1 special-pattern injection from user head HTML; /<head(?:\s[^>]*)?>/i handles <head data-theme="dark"> and won't match <header>. __NEXT_MAIN__bodyMarker runs before the head regex, so no rendered body content is in scope.
  • Dev (dev-server.ts) — The per-response-UUID SSR_HEAD_START/END markers + hoist survive transformIndexHtml and reorder the collected head ahead of Vite's prepended dev scripts. Using a UUID rather than a fixed sentinel is the right call, and the new preserves inline Head content containing the old fixed dev marker test locks that in.
  • head.ts shim — Splitting the combined effect into mount-only cleanup ([]) + set-only update (no-deps) correctly fixes the Fast Refresh re-append bug; isEqualHeadNode + document.title ownership faithfully port head-manager.ts.

No blocking issues. One divergence from the Next.js reference worth noting inline, plus confirmation of the pre-existing malformed-Document edge case already raised in prior reviews.

Comment thread packages/vinext/src/shims/head.ts
Comment thread packages/vinext/src/server/pages-page-response.ts
Comment thread packages/vinext/src/server/dev-server.ts
@ask-bonk

ask-bonk Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Review posted successfully to PR #2909.

Summary

Verdict: No blocking issues. The charset-ordering fix is well-designed and dev/prod parity is maintained. I read all four modified source files, cross-referenced Next.js's head-manager.ts source directly, and ran the targeted unit suites locally (tests/head.test.ts 57/57, tests/pages-page-response.test.ts 56/56 — all pass).

Verified correct:

  • Prod (pages-page-response.ts) — Function-replacer insert after the opening <head> avoids $&/$1 injection; the regex handles attributed heads and won't match <header>; __NEXT_MAIN__ → body marker runs first so no body content is in scope.
  • Dev (dev-server.ts) — Per-response-UUID markers (a genuine improvement over a fixed sentinel) survive transformIndexHtml, and the new "preserves inline Head content containing the old fixed dev marker" test locks that behavior in.
  • head.ts shim — The effect split (mount-only cleanup + no-deps update) correctly fixes the Fast Refresh re-append bug; isEqualHeadNode and document.title ownership faithfully port Next.js's head-manager.ts.

Three non-blocking observations posted inline:

  1. head.ts:563 — New finding: the sort's indexOf returns -1 for tag types not in the list (e.g. <template>), sorting them ahead of the charset. Also, noscript is included here but isn't in Next.js's list. Posted a suggestion mapping -1 → Infinity so unknown types append last.
  2. pages-page-response.ts:344 — Corroborated the pre-existing malformed-Document edge case (no <head> → charset dropped). Not a regression.
  3. dev-server.ts:498 — Dev analogue of Published npm package ships sourcemaps pointing to missing source files #2: markers are sliced unconditionally before the re-insert regex; a fallback append would make the charset unlosable.

All are non-blocking; the previously flagged trace-metadata dev/prod parity concern is actually fixed by this PR (dev now routes trace HTML through getTailHeadHTML).

github run

@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

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.

Reviewed the charset-ordering fix end-to-end. I verified all four moving parts against the Next.js head-manager.ts source and ran the targeted suites locally — tests/head.test.ts (57) and tests/pages-page-response.test.ts (56) both pass, and vp check on the three source files is clean.

Verified correct:

  • Prod (pages-page-response.ts) — Function-replacer insert after the opening <head> avoids $&/$1 special-pattern injection from user head HTML; /<head(?:\s[^>]*)?>/i handles <head data-theme="..."> and won't match <header>. Crucially, the head regex runs while html still holds the bodyMarker placeholder — the real body stream is spliced in later at markerIndex (line 636) — so there is no risk of matching a stray <head> inside rendered body content.
  • Dev (dev-server.ts) — Per-response-UUID SSR_HEAD_START/END comment markers survive transformIndexHtml, and the hoist correctly reorders the collected head ahead of Vite's prepended dev scripts and custom Document children. The UUID (vs a fixed sentinel) is the right call and the new preserves inline Head content containing the old fixed dev marker test locks it in. Trace-metadata dev/prod parity is now aligned — dev routes trace HTML through getTailHeadHTML, so it lands at the head tail in both modes, matching Next.js. The error-page manual-default-Document branch also gets trace metadata, and clientTraceMetadata is plumbed through errorPageContext to every renderErrorPage call.
  • head.ts shim — Splitting the combined effect into mount-only cleanup ([]) + set-only update (no deps) correctly fixes the Fast Refresh re-append bug; isEqualHeadNode, the document.title ownership, and the [meta, base, link, style, script] type-ordering faithfully port head-manager.ts (confirmed against canary source). The delete+set in _updateClientHeadInstance reproduces Next.js's last-writer-wins dedupe via Map iteration order.

No blocking issues. Two small deliberate divergences from Next.js and one pre-existing edge case, all non-blocking, noted inline.

Comment thread packages/vinext/src/shims/head.ts
Comment thread packages/vinext/src/server/pages-page-response.ts
@ask-bonk

ask-bonk Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Review posted successfully to PR #2909.

Summary

Verdict: No blocking issues. The charset-ordering fix is well-designed and dev/prod parity is maintained. I verified all four moving parts against the Next.js head-manager.ts canary source and ran the targeted suites locally (tests/head.test.ts 57/57, tests/pages-page-response.test.ts 56/56, vp check clean on the three source files).

Verified correct:

  • Prod (pages-page-response.ts) — Function-replacer insert avoids $&/$1 injection; regex handles attributed heads, won't match <header>, and runs while html still holds the bodyMarker placeholder (body spliced in later at line 636), so no body content is in scope.
  • Dev (dev-server.ts) — Per-response-UUID markers survive transformIndexHtml and hoist ahead of Vite's dev scripts. Trace-metadata dev/prod parity is now aligned (both route through getTailHeadHTML), and clientTraceMetadata is plumbed through errorPageContext to every renderErrorPage call including the manual default-Document branch.
  • head.ts shim — Effect split (mount-only cleanup + set-only update) fixes the Fast Refresh re-append bug; isEqualHeadNode, document.title ownership, type-ordering, and the delete+set last-writer-wins dedupe all faithfully port head-manager.ts.

Two non-blocking observations posted inline:

  1. head.ts:563 — Two deliberate divergences from Next.js (noscript added to the order list; unknown types sort last via -1 → Infinity). Both sensible; suggested a one-line test to pin the unknown-type fallback.
  2. pages-page-response.ts:344 — Pre-existing edge case (malformed Document with no <head> drops the charset), not a regression; optional </head> fallback would make it unlosable, symmetric with the dev hoist at dev-server.ts:498.

These corroborate the prior bonk reviews. The fix is sound and ready.

github run

@james-elicx
james-elicx merged commit d1fb2eb into cloudflare:main Aug 14, 2026
55 checks passed
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

2 participants