Skip to content

fix(pages): refresh next/head tags when regenerating ISR HTML - #2729

Open
NathanDrake2406 wants to merge 4 commits into
cloudflare:mainfrom
NathanDrake2406:fix/pages-isr-head-refresh
Open

fix(pages): refresh next/head tags when regenerating ISR HTML#2729
NathanDrake2406 wants to merge 4 commits into
cloudflare:mainfrom
NathanDrake2406:fix/pages-isr-head-refresh

Conversation

@NathanDrake2406

@NathanDrake2406 NathanDrake2406 commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Overview

Goal Keep next/head output in sync with the data an ISR regeneration renders
Core change Read the regenerated head inside the ISR render pass and swap it into the cached shell
Key boundary next/head tags only; _document-owned shell markup stays cached
Expected impact ISR pages with data-derived <title>/meta stop serving permanently stale metadata

Why

ISR regeneration re-renders the page body but reuses the cached shell verbatim. renderPagesIsrHtml() rewrites the body region and the __NEXT_DATA__ script, and everything before <div id="__next"> (the entire <head>) is carried over from the previously cached HTML.

That freezes the head at whatever the first cache-filling render produced. For a page whose metadata derives from getStaticProps:

export async function getStaticProps() { /* ... revalidate: 60 */ }

export default function Product({ product }) {
  return (
    <>
      <Head><title>{product.name}</title></Head>
      <h1>{product.name}</h1>
    </>
  )
}

renaming the product updates the <h1> on the next revalidation but not the <title>. Body and metadata drift apart and never reconverge for the lifetime of the entry. Next.js re-runs the whole render, including the document, on every background revalidate, so the head rotates with the data there.

The reason the head was dropped rather than deliberately excluded: next/head tags are not returned by the render, they accumulate as a side effect of it into an AsyncLocalStorage scope (runWithHeadState) that closes when the pass returns. By the time renderPagesIsrHtml() has the fresh body string, the collected head is already out of reach. Reading it requires a hook inside the pass.

What changed

Scenario Before After
ISR regeneration, page with data-derived next/head tags Head frozen at first cache-fill render Head reflects the regenerated data
ISR regeneration, app whose _document overrides getInitialProps Head frozen at first cache-fill render Unchanged — refresh deliberately skipped, see below
ISR regeneration, _document-rendered head children / CSS-in-JS styles From cached shell Unchanged, still from cached shell
Regeneration collecting an empty head n/a Cached head left intact rather than emptied
Cache entries written before this change n/a Refreshed normally; the anchor attribute already exists in them
Foreground render, non-ISR paths Unchanged Unchanged

The collector's tags are located in the cached shell via the data-next-head attribute that getSSRHeadHTML() stamps on everything it emits, which is also what Next.js uses to reconcile the head on the client. Because ssrHeadHTML is concatenated ahead of trace meta and _document styles in buildPagesShellHtml(), the tags form one contiguous run, and replacing first-match-start through last-match-end swaps exactly that run.

Why _document.getInitialProps apps opt out

enhancePageElement is non-optional in createPagesPageHandler, so in production a user _document.getInitialProps override always resolves its head through runDocumentRenderPage() — with a real ctx.renderPage plus the request-scoped req, res, pathname, query and asPath. pages-page-response.ts only falls back to the bare callDocumentGetInitialProps() on the skipped branch, which by definition means there is no override.

Regeneration cannot reproduce that context without running the whole document pipeline again, which is out of scope here. Collecting a head anyway would mean swapping a complete cached head for one built from a _document that threw on ctx.req (swallowed) or emitted fallback tags. So those apps keep the cached head — their pre-PR behaviour — and everyone else gets the refresh. For every app that does refresh, the _document helper was a no-op regardless, since it early-returns on the unmodified shim default.

Locating the closing </head>

headChildToHTML() escapes </script and </style in inline bodies but not </head>, so a next/head inline script may legitimately contain that string. Raw-text elements are therefore blanked (length-preservingly, so indices still map onto the original shell) before the boundary lookup, rather than reading the first </head> substring as markup.

Maintainer review path
  1. packages/vinext/src/entries/pages-server-entry.ts for the onHeadReady hook and why it sits inside the ALS scope.
  2. packages/vinext/src/server/pages-page-data.ts for refreshCachedHeadTags(), the swap boundary, and the raw-text-aware </head> lookup.
  3. packages/vinext/src/server/pages-page-handler.ts for collectIsrHeadHTML and the _document.getInitialProps opt-out.
  4. packages/vinext/src/server/document-initial-head.ts for hasUserDocumentGetInitialProps(), which keeps the shim-default identity check in one place.
  5. tests/pages-page-data.test.ts for the regression proof.
Validation
  • Regression coverage in tests/pages-page-data.test.ts: the regenerated shell carries the fresh next/head tags while leaving _document-owned head markup in place; an empty collection does not delete the cached head; and a cached shell whose inline script body contains a literal </head> still swaps the whole run.
  • Confirmed the head-swap test fails without the fix by reverting the refreshCachedHeadTags() call, and confirmed the </head>-in-script test fails against the previous plain indexOf() boundary. Restored after each.
  • Ran vp test run over pages-page-data, pages-page-handler, pages-page-response, pages-isr-query-context, isr-cache, document, head, and script-head-ordering.
  • Ran vp check repo-wide: no formatting, lint, or type errors.
  • Pre-commit hook regenerated entry template snapshots and ran the full check, staged tests, and knip; all passed.

Full local suite and e2e were not run; leaving those to CI.

Risk / compatibility
  • Public API: unchanged. onHeadReady and collectIsrHeadHTML are optional internal parameters; omitting them preserves the previous behaviour exactly.
  • Cached data: no cache schema change. Existing entries already carry data-next-head, so they refresh without invalidation.
  • Failure mode is conservative: if the anchor is not found, the closing </head> is missing, the app overrides _document.getInitialProps, or the regeneration collects no head, the cached head is returned untouched.
Non-goals
  • Refreshing _document-rendered head children, CSS-in-JS styles, or the head of apps that override _document.getInitialProps. All require running the full document pipeline on the regeneration path, which is a larger change than this one.

NathanDrake2406 and others added 2 commits July 28, 2026 00:50
ISR regeneration re-renders the page body but reuses the cached shell
verbatim, so the <head> stays frozen at whatever the first cache-filling
render produced. A page whose title or meta tags derive from
getStaticProps data serves an updated body under permanently stale
metadata, and the two never reconverge for the lifetime of the entry.

next/head tags accumulate as a side effect of rendering into an
AsyncLocalStorage scope that closes when the regeneration pass returns,
so the refreshed head has to be read inside that pass. The render
callback now accepts an onHeadReady hook that runs after the render and
before the scope unwinds.

The collected tags are located in the cached shell through the
data-next-head attribute that getSSRHeadHTML stamps on everything it
emits, which keeps the swap working on entries written before this
change. _document.getInitialProps runs in the same hook because its head
tags share that collector; omitting it would drop them from the
regenerated shell.

_document-rendered head children and CSS-in-JS styles still come from
the cached shell, since regeneration does not re-render _document.
@NathanDrake2406

Copy link
Copy Markdown
Contributor Author

@codex review

@pkg-pr-new

pkg-pr-new Bot commented Jul 27, 2026

Copy link
Copy Markdown

Open in StackBlitz

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

commit: de0e1b7

@github-actions

github-actions Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared de0e1b7 against base c9a4a84 using alternating same-runner rounds. Next.js was unchanged and skipped.

0 improved · 0 regressed · 6 within ±1.5%

Scenario Framework Baseline Current Change
Client bundle size (gzip) vinext 132.4 KB 132.4 KB ⚫ -0.0%
Client entry size (gzip) vinext 119.8 KB 119.8 KB ⚫ -0.0%
Dev server cold start vinext 3.00 s 3.01 s ⚫ +0.3%
Production build time vinext 3.18 s 3.19 s ⚫ +0.4%
RSC entry closure size (gzip) vinext 105.6 KB 105.6 KB ⚫ +0.0%
Server bundle size (gzip) vinext 179.8 KB 179.8 KB ⚫ +0.0%

View detailed results and traces

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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e5a1d2a2be

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/vinext/src/server/pages-page-handler.ts Outdated
Comment thread packages/vinext/src/server/pages-page-data.ts Outdated
Two problems in the regeneration head swap, both raised in review.

The regeneration hook called _document.getInitialProps through the bare
callDocumentGetInitialProps helper, whose context deliberately omits req,
res, pathname, query and asPath and supplies a no-op renderPage. That is
not the contract the initial render uses: enhancePageElement is always
wired in the prod handler, so any user override resolves its head through
runDocumentRenderPage with the real request context instead. A Document
reading ctx.req would therefore throw (swallowed by the helper) or emit
fallback tags during regeneration, and the swap would replace a complete
cached head with that degraded one.

Reproducing the full document pipeline on regeneration is a much larger
change, so those apps now keep the cached head, which is the behaviour
they had before the refresh existed. Everyone else — no _document, or one
that never overrides getInitialProps — is unaffected, and for them the
helper was a no-op anyway, so the hook is now just getSSRHeadHTML.

Locating the closing head with indexOf also read raw-text content as
markup. headChildToHTML escapes </script and </style in inline bodies but
not </head>, so a next/head script may legitimately contain that string.
The scan then ended mid-element, matched nothing, and silently skipped the
refresh — or, with a differently shaped shell, inserted the fresh head
while leaving later stale tags in place. Raw-text elements are blanked
length-preservingly before the boundary lookup so indices still map onto
the original shell.
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.

1 participant