Skip to content

fix(pages): match Next.js script loader placement#2566

Open
james-elicx wants to merge 12 commits into
mainfrom
codex/pages-router-script-loader-28938793088
Open

fix(pages): match Next.js script loader placement#2566
james-elicx wants to merge 12 commits into
mainfrom
codex/pages-router-script-loader-28938793088

Conversation

@james-elicx

Copy link
Copy Markdown
Member

Summary

  • place Pages Router afterInteractive and lazyOnload scripts as body siblings after #__NEXT_DATA__
  • add data-nscript="beforeInteractive" to server-rendered Pages scripts
  • collect and hoist legacy page-level beforeInteractive scripts ahead of framework asset chunks
  • suppress already-hoisted scripts during hydration to avoid duplicate rendering and hydration mismatches

Next.js parity

Ported from Next.js v16.3.0-canary.80:

  • test/e2e/script-loader/script-loader.test.ts
  • packages/next/src/client/script.tsx
  • packages/next/src/pages/_document.tsx
  • packages/next/src/server/render.tsx

The Pages response helper is shared by dev, production, and Worker request paths, so the placement and collection behavior stays aligned across runtimes.

Validation

  • vp test run tests/script.test.ts tests/pages-page-response.test.ts — 88 passed
  • vp check packages/vinext/src/shims/script.tsx packages/vinext/src/server/pages-document-initial-props.ts packages/vinext/src/server/pages-page-response.ts tests/script.test.ts tests/pages-page-response.test.ts — clean
  • vp run vinext#build — passed
  • exact targeted Next.js deploy wrapper against /Users/jamesanderson/Developer/vinext/.nextjs-ref-v16.3.0-canary.80:
    • test/e2e/script-loader/script-loader.test.ts — 11 passed
    • includes afterInteractive, lazyOnload, modern beforeInteractive, and older page-level beforeInteractive
    • no browser hydration error in the final run

Run source: 28938793088.

@pkg-pr-new

pkg-pr-new Bot commented Jul 8, 2026

Copy link
Copy Markdown

Open in StackBlitz

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

commit: 73e558a

@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor
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
workers-cache preview production
web preview production

Comment thread packages/vinext/src/server/pages-page-response.ts Fixed
@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared 73e558a against base aa1b666 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 126.6 KB 126.6 KB ⚫ 0.0%
Client entry size (gzip) vinext 120.6 KB 120.6 KB ⚫ 0.0%
Dev server cold start vinext 2.22 s 2.22 s ⚫ +0.2%
Production build time vinext 2.49 s 2.31 s 🟢 -7.2%
RSC entry closure size (gzip) vinext 98.5 KB 98.5 KB ⚫ 0.0%
Server bundle size (gzip) vinext 165.1 KB 165.2 KB ⚫ +0.0%

View detailed results and traces

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

@james-elicx

Copy link
Copy Markdown
Member Author

/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.

Review: fix(pages): match Next.js script loader placement

Reviewed the script-loader extraction, the span-stripping placement logic, and dev/prod/Worker parity. One real dev/prod parity bug, plus a couple of notes.

1. Dev/prod parity gap: page-level beforeInteractive scripts are not hoisted in dev

In production (and Workers), renderPagesPageResponse wraps the page tree in a BeforeInteractiveContext.Provider collector and hoists page-level <Script strategy="beforeInteractive"> into <head>:

// packages/vinext/src/server/pages-page-response.ts
const beforeInteractiveScripts: BeforeInteractiveInlineScript[] = [];
const wrapWithBeforeInteractiveCollector = (element) =>
  React.createElement(BeforeInteractiveContext.Provider, { value(script) { beforeInteractiveScripts.push(script); } }, element);
...
wrapPageElement: wrapWithBeforeInteractiveCollector,      // passed to runDocumentRenderPage
...
wrapWithBeforeInteractiveCollector(React.createElement(React.Fragment, null, options.createPageElement(renderProps)))  // streaming path

The dev server (packages/vinext/src/server/dev-server.ts) does not do this. It only collects _document-registered scripts (documentBeforeInteractiveScripts) via DocumentScriptContext, and renders the page element with no BeforeInteractiveContext.Provider:

  • runDocumentRenderPage({ ... }) is called without wrapPageElement (compare pages-page-response.ts:599).
  • The streaming page element (dev-server.ts:414, and the tree built at dev-server.ts:1717-1730) is passed to streamPageToResponse unwrapped (only withScriptNonce).

Consequence: a page-level <Script strategy="beforeInteractive"> (i.e. defined in a page/component, not in _document) is hoisted into <head> in prod but rendered inline in the body in dev. This is exactly the behavior the PR is trying to fix, and prod has a unit test for it (tests/pages-page-response.test.ts:215 "hoists legacy page beforeInteractive scripts ahead of Pages asset chunks") but dev has no equivalent and no coverage.

Per AGENTS.md ("Always check dev and prod server parity"), the dev path should mirror the prod collection/hoisting. Suggest:

  • add a wrapWithBeforeInteractiveCollector in the dev server, pass it as wrapPageElement to runDocumentRenderPage, and wrap the streaming element with it;
  • inject the collected page-level scripts into <head> alongside documentBeforeInteractiveHTML;
  • add a dev-mode e2e/unit test that a page-level beforeInteractive script lands in <head> (the existing script-before-ready.tsx fixture uses the default afterInteractive strategy, so it does not cover this).

Note the ordering is also not identical even for the _document scripts: dev injects documentBeforeInteractiveHTML immediately after <head> opens (before fontHeadHTML/ssrHeadHTML), while prod prepends them to assetTags and injects them after fontHeadHTML/ssrHeadHTML (${fontHeadHTML}${ssrHeadHTML}\n ${assetTags}\n</head>). Worth confirming which position matches Next.js and aligning the two paths.

2. CodeQL "polynomial regex" alert appears already resolved

The github-advanced-security comment flags a polynomial regex on <span . That was the original 9dd1cba implementation:

html.replace(/<span(?:\s[^>]*)?>\s*<!-- __NEXT_SCRIPTS__ -->\s*<\/span>/, ...)

HEAD has since replaced this with indexOf/lastIndexOf-based scanning in both pages-page-response.ts and dev-server.ts, so the ReDoS surface is gone. The alert should clear on the next scan — no action needed beyond confirming it does. The VALID_ATTR_NAME regex in before-interactive-head.ts is anchored and linear, so it is not a concern.

3. optimizeDeps.include now always set (index.ts)

config() changed from conditionally spreading include to always emitting include: [...incomingInclude, react, react-dom, react-dom/client, react/jsx-runtime, react/jsx-dev-runtime]. This is deduped via new Set, so it's safe, but it's a behavioral change for every project (previously include was omitted when empty). Worth a one-line rationale in the PR body on why the new script-loader client import made this necessary — otherwise it reads as unrelated to the stated change.

Things that look correct

  • loadClientScript dedup semantics (LoadCache/ScriptCache, onReady NOT firing on the in-flight dedup path) match Next.js loadScript exactly.
  • initScriptLoader order (run handleClientScriptLoad per item, then seed beforeInteractive/beforePageRender from the DOM) matches Next.js initScriptLoader + addBeforeInteractiveToCache.
  • Client-side suppression of already-hoisted beforeInteractive scripts (hasHoistedScript || hasAppNavigationRuntimeBootstrap()) and the onReady-on-remount fix (seeding loadedScripts from the hoisted DOM node) are sound and covered by the new e2e tests.
  • Span-stripping guards (onlyWhitespaceBefore/onlyWhitespaceAfter, spanClose + 7 for </span>) correctly target only the NextScript wrapper span and no-op safely when absent.

Targeted tests pass locally: tests/script.test.ts + tests/pages-page-response.test.ts (102), tests/entry-templates.test.ts + tests/build-optimization.test.ts + tests/pages-router.test.ts (539), and vp check is clean on the changed source files.

@ask-bonk

ask-bonk Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2566. Summary of findings:

Review summary

1 real bug (dev/prod parity): Page-level <Script strategy="beforeInteractive"> in the Pages Router is hoisted into <head> in production/Workers (renderPagesPageResponse wraps the tree in BeforeInteractiveContext.Provider), but the dev server does not do this — it only collects _document-registered scripts and renders page-level beforeInteractive scripts inline in the body. Prod has a unit test for this; dev has none. This violates the AGENTS.md dev/prod parity rule. I also flagged that even for _document scripts the injection position differs between dev (right after <head> open) and prod (after fonts/head, prepended to assetTags).

CodeQL alert (already resolved): The flagged polynomial regex on <span was the original 9dd1cba implementation; HEAD already replaced it with linear indexOf/lastIndexOf scanning, so the ReDoS surface is gone.

Note: index.ts now always sets optimizeDeps.include with React deps (previously conditional) — safe due to new Set dedup, but deserves a rationale in the PR body.

Verified correct: dedup semantics (onReady not firing on in-flight dedup, matching Next.js), initScriptLoader ordering, client-side hoisted-script suppression + onReady-on-remount fix, and the span-stripping guards. Targeted tests (102 + 539) pass and vp check is clean.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@james-elicx

Copy link
Copy Markdown
Member Author

Exact-head CI exposed a related streaming regression from the allReady remediation: production Pages streaming waited ~606ms and the first chunk no longer contained the Suspense fallback. Fixed at 23081da7e by removing the allReady wait and its unsupported suspended-script coverage while preserving shell-time page-level beforeInteractive hoisting.

Validation: targeted streaming 3/3, Pages dev script 3/3, Pages prod 14/14, exact Next.js v16.3.0-canary.80 script-loader 11/11, targeted check and vinext#build pass. Requesting fresh independent review before retriggering Bonk.

@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.

Review: fix(pages): match Next.js script loader placement

Re-reviewed after the latest commits. The previous review's #1 (page-level beforeInteractive not hoisted in dev) is now resolved — the dev server wraps the page tree with beforeInteractiveCollector.wrapPageElement and injects into <head>, with matching dev + prod e2e coverage. Good.

However, the newest commit (8d7858f "await suspended scripts before document render") introduces a production streaming regression plus two threshold failures. vp test run tests/pages-router.test.ts currently fails (5 tests).

1. BLOCKER — await ...allReady breaks incremental SSR streaming in production

renderPagesPageResponse now awaits the full render before building the shell:

// packages/vinext/src/server/pages-page-response.ts:629-632
bodyStream = await options.renderToReadableStream(pageElement);
...
await waitForBeforeInteractiveCollection(bodyStream);   // awaits bodyStream.allReady

waitForBeforeInteractiveCollection awaits stream.allReady, which only resolves once every Suspense boundary has resolved. Because this happens before buildPagesCompositeStream runs, the shell prefix is no longer flushed ahead of deferred content — the whole page is fully rendered before the first byte goes out. On main there is no such await; the body stream is passed straight through and streamed incrementally.

This is confirmed by the failing production streaming tests:

  • streams Pages SSR responses incrementally in production with br compressionfirstChunkMs no longer < 400, and the first chunk now contains "Delayed stream content loaded" instead of the "Loading delayed chunk..." fallback.
  • ...with gzip compression — same.
  • strips stale content-length from streamed Pages SSR responses when gSSP sets one — same.
  • strips middleware-provided content-length when rewriting to a streamed Pages SSR response — first chunk now contains the fully-resolved <head> + resolved body.

Note this over-shoots Next.js: the App Router path in this same PR (app-ssr-entry.ts:697-704) explicitly documents that beforeInteractive ordering "applies to scripts rendered in the initial shell" and that scripts rendered later inside a Suspense boundary fall back to their inline location. Blocking the entire stream on allReady to hoist a beforeInteractive script that lives inside a suspended boundary is a behavior Next.js itself does not guarantee, and it costs incremental streaming (and TTFB) on every Pages SSR response, suspended script or not.

Suggested direction: don't block the shell on allReady. Collect only the scripts available at shell-flush time (mirroring the App Router snapshot semantics), and let a page-level beforeInteractive script that renders inside a late Suspense boundary fall back to inline — matching the documented App Router contract. The dev server (dev-server.ts:427) has the identical await waitForBeforeInteractiveCollection(bodyStream) and the same streaming hazard; fix both together for dev/prod parity per AGENTS.md.

2. Client entry bundle exceeds the 25 KB budget

produces client bundle with page chunks and SSR manifest fails: expected 25643 to be less than 25600. Pulling initScriptLoader (and transitively script-loader.ts, ~261 lines) into pages-client-entry.ts pushed the hydration-bootstrap entry chunk just over the 25 KB budget (the test comment: "just the hydration bootstrap, not the entire React framework").

This is a real regression against a deliberate budget, not just a stale threshold. Prefer keeping the entry lean — e.g. dynamically import the script-loader init only when nextData.scriptLoader is present, so pages with no <Script> usage don't pay for it — rather than bumping the limit. If the size is genuinely justified, that should be called out explicitly and the threshold updated with a rationale.

3. Dev/prod head-position divergence for hoisted beforeInteractive scripts

Even setting aside the streaming bug, the injection position differs between the two paths:

  • dev (dev-server.ts:543-552) splices beforeInteractiveHTML immediately after <head> opens, before fontHeadHTML/ssrHeadHTML.
  • prod (pages-page-response.ts:372, 406-407) prepends the scripts to assetTags and injects them after fontHeadHTML/ssrHeadHTML (${fontHeadHTML}${ssrHeadHTML}\n ${assetTags}).

The App Router reference path in this repo splices beforeInteractive scripts "immediately after <head ...> opens" (app-ssr-stream.ts:426-428, before-interactive-head.ts:13), specifically so the script precedes React-hoisted resource hints and font <link rel="stylesheet"> (the no-flash pattern the shim comment at script.tsx:426-433 describes). The dev position matches that; the prod position (after fonts) diverges from both dev and the App Router contract. The current e2e tests don't catch this because they only assert pageBeforeDocument / pageInHead, not position relative to font links. Align prod to inject before fontHeadHTML/ssrHeadHTML (matching dev + App Router) and add an assertion pinning script-before-font ordering.

Things that look correct

  • loadClientScript dedup semantics (LoadCache/ScriptCache, onReady NOT firing on the in-flight dedup path) match Next.js loadScript.
  • initScriptLoader ordering (run handleClientScriptLoad per item, then seed beforeInteractive/beforePageRender from the DOM) matches Next.js.
  • Client-side suppression of already-hoisted scripts (hasHoistedScript || hasAppNavigationRuntimeBootstrap()) and the onReady-on-remount seeding are sound.
  • Span-stripping guards (onlyWhitespaceBefore/onlyWhitespaceAfter, spanClose + 7) correctly target only the NextScript wrapper span and no-op safely. The double .replace(placeholder, scripts) after the span-strip branch is safe (placeholder already removed).
  • CodeQL polynomial-regex alert is stale — HEAD replaced the <span ...> regex with linear indexOf/lastIndexOf scanning in both pages-page-response.ts and dev-server.ts. VALID_ATTR_NAME is anchored/linear.
  • optimizeDeps.include now always set with React deps — deduped via new Set, safe; still worth a one-line rationale in the PR body.

tests/script.test.ts + tests/pages-page-response.test.ts (102) pass; tests/pages-router.test.ts fails (5) as detailed above.

@ask-bonk

ask-bonk Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2566.

Summary

The previous review's main concern (page-level beforeInteractive not hoisted in dev) has been fixed. But I found the latest commit (8d7858f) introduced regressions — tests/pages-router.test.ts fails with 5 tests:

1 BLOCKER — production streaming broken. renderPagesPageResponse now calls await waitForBeforeInteractiveCollection(bodyStream), which awaits stream.allReady before the shell is built. That blocks the response until every Suspense boundary resolves, killing incremental SSR streaming (pages-page-response.ts:632). On main there's no such await. This makes 4 production streaming tests fail — the first chunk now contains fully-resolved content instead of the Suspense fallback. It also over-shoots Next.js, whose own App Router path (in this same PR) documents that beforeInteractive ordering only applies to the initial shell. The dev server has the identical hazard.

Bundle budget exceeded. Pulling initScriptLoader/script-loader.ts into pages-client-entry.ts pushed the hydration entry chunk to 25643 bytes, over the 25 KB budget. Suggested a lazy import gated on nextData.scriptLoader.

Dev/prod head-position divergence. Dev injects hoisted scripts immediately after <head> opens (matching the App Router contract); prod injects them after fontHeadHTML/ssrHeadHTML. Current e2e tests don't catch the font-vs-script ordering.

Verified correct: dedup semantics, initScriptLoader ordering, client-side suppression + onReady seeding, span-stripping guards, and the stale CodeQL alert (already fixed with linear scanning).

github run

@james-elicx

Copy link
Copy Markdown
Member Author

The latest Bonk review is stale relative to the exact commit it labels: all findings reference waitForBeforeInteractiveCollection and the suspended fixture from 8d7858f, but both are deleted at exact head 23081da7e (which is byte-for-byte identical to reviewed cb2e8472 for implementation). Targeted tests/pages-router.test.ts streaming assertions now pass 3/3, including first chunk <400ms with fallback; dev script 3/3, prod 14/14, and pinned canary 11/11 also pass.

/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.

Review: fix(pages): match Next.js script loader placement

Re-reviewed at exact head 23081da7e. The prior Bonk review is confirmed stale — all its findings referenced waitForBeforeInteractiveCollection and the suspended-script fixture from 8d7858f, both of which the final commit (23081da "preserve suspense streaming for scripts") deletes.

Stale findings — verified resolved at head

  • BLOCKER #1 (await ...allReady breaks streaming) — resolved. waitForBeforeInteractiveCollection and both call sites (pages-page-response.ts, dev-server.ts) are gone (git diff 8d7858f..23081da). Both paths now read beforeInteractiveCollector.scripts immediately after await renderToReadableStream(...), which resolves at shell-ready time — so shell-level beforeInteractive scripts are collected and hoisted, while scripts inside a still-pending Suspense boundary fall back to inline. That matches the documented App Router shell-snapshot contract and restores incremental streaming. tests/pages-router.test.ts now passes 352/352, including all four production streaming tests (firstChunkMs < 400, first chunk carries the Suspense fallback).
  • #2 (bundle budget 25643 > 25600) — no longer failing. The budget assertion at tests/pages-router.test.ts:4565 is unchanged (< 25 * 1024) and the "produces client bundle with page chunks and SSR manifest" test passes. (Note below: the entry still eagerly imports initScriptLoader; headroom is thin.)
  • CodeQL polynomial regex — stale. HEAD scans with indexOf/lastIndexOf in both pages-page-response.ts:381-392 and dev-server.ts:495-506; VALID_ATTR_NAME is anchored/linear.

1. Dev/prod parity: hoisted beforeInteractive position diverges (still present)

This is the one substantive finding that survives at head — and it directly undercuts the PR's stated goal. The prod Pages path and the dev path inject hoisted beforeInteractive scripts at different positions within <head>:

  • dev (dev-server.ts:538-547) splices beforeInteractiveHTML immediately after <head ...> opens, before fontHeadHTML/headHTML/asset tags.
  • prod (pages-page-response.ts:368-378) prepends the scripts to assetTags and injects the whole block after ${fontHeadHTML}${ssrHeadHTML} (<head> → fonts → next/head → beforeInteractive → other asset tags).

So in prod the beforeInteractive script runs after font stylesheets and next/head <link rel="stylesheet">; in dev it runs before them. This breaks the exact no-flash contract this PR documents:

  • before-interactive-head.ts:13 — "ready to splice immediately after <head ...> opens"
  • script.tsx:426-433 — "beforeInteractive scripts need to run BEFORE any stylesheets, modulepreload links, or other resource hints ... the SSR pipeline emits it immediately after <head> opens — guaranteeing it precedes every React-emitted hint"
  • App Router reference path (app-ssr-stream.ts:426-428) does exactly this.

Dev matches the contract; prod diverges. This is a regression introduced by this PR — on base (aa1b666) prod assetTags had no beforeInteractive prefix and the App Router before-interactive-head helper wasn't used for Pages. Per the AGENTS.md dev/prod parity rule, align prod to inject before ${fontHeadHTML}${ssrHeadHTML} (matching dev + App Router).

The existing e2e (tests/e2e/pages-router/script.spec.ts) can't catch this — it asserts pageInHead/pageBeforeDocument/documentBeforeVite but never position relative to font/stylesheet links. Add an assertion pinning beforeInteractive-before-font-stylesheet ordering so the two paths can't silently drift again.

2. Note: initScriptLoader is still imported eagerly (thin budget headroom)

pages-client-entry.ts:145 statically imports initScriptLoader and always calls it (:220). The prior review's suggestion to gate it on nextData.scriptLoader was not adopted. The budget test passes today, but the margin is small; a lazy import for pages with no <Script> usage would give headroom and keep the hydration bootstrap lean. Non-blocking.

3. Note: optimizeDeps.include now always set (index.ts:2918)

config() always emits include: [react, react-dom, react-dom/client, react/jsx-runtime, react/jsx-dev-runtime, ...incoming] (previously omitted when empty). Deduped via new Set, so safe, but it's a behavioral change for every project and reads as unrelated to script loading. Worth a one-line rationale in the PR body on why the client script-loader import made it necessary.

Verified correct

  • loadClientScript dedup semantics — in-flight scriptCache hit does not fire onReady, matching Next.js loadScript (script-loader.ts:158-172).
  • initScriptLoader ordering — runs handleClientScriptLoad per item, then seeds beforeInteractive/beforePageRender from the DOM (script-loader.ts:247-261), matching Next.js.
  • Client-side suppression of already-hoisted scripts + onReady-on-remount seeding are sound and e2e-covered.
  • Span-stripping guards (onlyWhitespaceBefore/onlyWhitespaceAfter, spanClose + 7) target only the NextScript wrapper span and no-op safely when absent, in both dev and prod.
  • Streaming shell-snapshot semantics are correct: reading beforeInteractiveCollector.scripts after the shell-ready await collects shell scripts without blocking on allReady.

Tests run at head 23081da7e

  • tests/pages-router.test.ts352 passed (incl. 4 prod streaming, first chunk < 400ms with fallback)
  • tests/script.test.ts + tests/pages-page-response.test.ts + tests/entry-templates.test.ts143 passed
  • tests/build-optimization.test.ts (bundle) budget unchanged and green

Verdict: the streaming BLOCKER is genuinely fixed and the prior review is stale. The remaining actionable item is the prod-vs-dev beforeInteractive head-position divergence (#1 above), which should be aligned + pinned with a test before merge since it's exactly the placement this PR is meant to fix.

@ask-bonk

ask-bonk Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Review complete. Posted a fresh independent review to PR #2566 at exact head 23081da7e.

Summary

Your claim is verified. The prior Bonk review was stale — every finding referenced waitForBeforeInteractiveCollection and the suspended fixture from 8d7858f, both of which the final commit 23081da deletes.

Stale findings confirmed resolved:

  • BLOCKER (allReady breaks streaming) — gone. Both call sites removed; tests/pages-router.test.ts passes 352/352 including all 4 prod streaming tests (first chunk < 400ms with the Suspense fallback). The shell-snapshot semantics are correct: beforeInteractiveCollector.scripts is read after the shell-ready await, so shell scripts hoist without blocking on allReady.
  • Bundle budget (25643 > 25600) — no longer failing; budget assertion unchanged and green.
  • CodeQL polynomial regex — stale; linear indexOf/lastIndexOf scanning in both files.

One real finding that survives at head (and undercuts the PR goal):

  • Dev/prod beforeInteractive head-position divergence. Dev splices hoisted scripts immediately after <head> opens (matching the documented no-flash / App Router contract in before-interactive-head.ts:13, script.tsx:426-433, app-ssr-stream.ts:426-428). Prod (pages-page-response.ts:368-378) folds them into assetTags, placed after ${fontHeadHTML}${ssrHeadHTML} — so in prod the script runs after font/next-head stylesheets, breaking the no-flash pattern. This is a PR-introduced regression (base had no beforeInteractive in prod assetTags). The e2e tests don't pin position relative to font links, so they miss it. Recommended: align prod to inject before fonts and add an ordering assertion.

Two non-blocking notes: initScriptLoader still eagerly imported (thin budget headroom), and optimizeDeps.include is now always set (safe, but deserves a PR-body rationale).

Verdict: the streaming BLOCKER is genuinely fixed and the prior review is stale; the head-position parity gap is the remaining actionable item.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

Addressed the exact-head Bonk placement finding at 73e558a6e: production now uses the same immediate-after-<head> insertion seam as dev/App Router, before font/head/asset output. Added unit and raw-response ordering coverage while retaining DOM hoist/dedupe checks.

Validation: response + production streaming 3/3, Pages dev script 3/3, pinned Next.js v16.3.0-canary.80 script-loader 11/11, targeted check and vinext#build pass. Requesting fresh independent review before the next Bonk gate.

@james-elicx

Copy link
Copy Markdown
Member Author

/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.

Review: fix(pages): match Next.js script loader placement

Re-reviewed at exact head 73e558a6e ("align beforeInteractive head ordering"). The prior review's one surviving finding — the dev/prod beforeInteractive head-position divergence — is now fixed, with test coverage. No blockers found at head.

Resolved: prod beforeInteractive head position now matches dev + App Router

On the previously reviewed commit, prod folded hoisted beforeInteractive scripts into assetTags (injected after ${fontHeadHTML}${ssrHeadHTML}), while dev spliced them immediately after <head> opens. This commit routes both paths through the same insertAfterHeadOpen seam:

  • prod (pages-page-response.ts:399, :408, :416) now calls insertBeforeInteractiveScripts(html, beforeInteractiveScripts)insertAfterHeadOpen (before-interactive-head.ts:74-81), splicing right after <head> opens, before fonts / next/head / asset tags. assetTags no longer carries a beforeInteractive prefix.
  • dev (dev-server.ts:542) uses the identical insertAfterHeadOpen on the transformed shell.

Both now match the App Router no-flash contract documented at script.tsx:426-433 and before-interactive-head.ts:11-13.

Ordering is now pinned by tests (the prior review's explicit ask):

  • Unit: pages-page-response.test.ts:243-245 asserts the hoisted script index < font stylesheet (/font.css) < next/head tag < asset chunk.
  • E2E: tests/e2e/pages-router/script.spec.ts:13 asserts raw-HTML id="page-before" precedes name="description", plus DOM pageInHead / pageBeforeDocument / documentBeforeVite and bodyCopies: 0.

Streaming shell-snapshot semantics verified correct

beforeInteractiveCollector.scripts is read (pages-page-response.ts:658) after await renderToReadableStream(pageElement) (:626), which resolves at shell-ready under react-dom/server.edge. Shell-level beforeInteractive scripts hoist; scripts inside a still-pending Suspense boundary fall back to inline — matching the App Router contract, without blocking the stream. No double collection: runDocumentRenderPage short-circuits skipped (pages-document-initial-props.ts:180-186) unless a user getInitialProps+renderPage (CSS-in-JS) path is active, in which case the streaming block (:610-627) is skipped.

Prod/Worker share renderPagesPageResponse via pages-page-handler.ts:761; dev mirrors it in dev-server.ts. All three paths aligned per the AGENTS.md dev/prod parity rule.

Stale findings from prior Bonk reviews — confirmed resolved at head

  • BLOCKER await ...allReady breaks streaming — gone. waitForBeforeInteractiveCollection and both call sites deleted. Prod streaming tests pass (first chunk < 400ms, carries the Suspense fallback).
  • Bundle budget 25643 > 25600 — no longer failing; < 25 KB assertion (pages-router.test.ts:4565) is green.
  • CodeQL polynomial regex on <span — stale. Both pages-page-response.ts:383 and dev-server.ts:500 scan with linear indexOf/lastIndexOf; VALID_ATTR_NAME (before-interactive-head.ts:9) is anchored/linear.

Non-blocking notes (unchanged from prior review, not adopted)

  1. initScriptLoader still eagerly imported (pages-client-entry.ts:145, called at :220). Budget headroom is thin; a lazy import gated on nextData.scriptLoader would keep the hydration bootstrap lean for pages with no <Script>. On an empty scriptLoader array initScriptLoader still runs two querySelectorAll calls — trivial, but avoidable.
  2. optimizeDeps.include now always set (index.ts:2921-2930) with React deps (previously omitted when incomingInclude was empty). Deduped via new Set, so safe, but it reads as unrelated to script-loader work — worth a one-line rationale in the PR body.

Verified correct

  • loadClientScript dedup — in-flight scriptCache hit does not fire onReady (script-loader.ts:158-172), matching Next.js loadScript.
  • initScriptLoader ordering — per-item handleClientScriptLoad, then seed beforeInteractive/beforePageRender from the DOM (script-loader.ts:247-261).
  • Client-side suppression of hoisted scripts (hasHoistedScript || hasAppNavigationRuntimeBootstrap(), script.tsx:489-494) + onReady-on-remount seeding (seedHoistedBeforeInteractiveScript) — sound and e2e-covered.
  • Span-stripping guards (onlyWhitespaceBefore/onlyWhitespaceAfter, spanClose + 7) target only the NextScript wrapper span and no-op safely in both dev and prod.
  • data-nscript="beforeInteractive" + data-vinext-script-key emitted on hoisted scripts, matching Next.js's DOM dedupe contract.

Tests run at head 73e558a6e

  • tests/script.test.ts + tests/pages-page-response.test.ts102 passed
  • tests/pages-router.test.ts prod streaming (br + gzip) — passed (first chunk 656ms/br with fallback)
  • tests/pages-router.test.ts "produces client bundle with page chunks and SSR manifest" (budget) — passed
  • tests/entry-templates.test.ts + tests/build-optimization.test.ts187 passed
  • vp check on all 8 changed source files — clean

Verdict: The head-position parity gap the PR was meant to fix is now genuinely aligned across dev/prod/Worker and pinned by unit + e2e ordering assertions. All prior blockers are resolved. Remaining items are non-blocking nits (eager initScriptLoader import, unconditional optimizeDeps.include rationale). LGTM.

@ask-bonk

ask-bonk Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2566 at exact head 73e558a6e.

Summary

The prior review's one surviving finding is fixed. The dev/prod beforeInteractive head-position divergence is resolved — commit 73e558a routes both paths through the same insertAfterHeadOpen seam, so hoisted scripts splice immediately after <head> opens (before fonts / next/head / asset tags) in prod, dev, and Worker alike. It's now pinned by:

  • Unit assertion (pages-page-response.test.ts:243-245): hoisted script < font stylesheet < head tag < asset chunk
  • E2E assertion (script.spec.ts:13): raw-HTML script before name="description", DOM placement + bodyCopies: 0

No blockers at head. All prior Bonk blockers are confirmed stale/resolved:

  • await ...allReady streaming regression — deleted; prod streaming tests green (first chunk <400ms with Suspense fallback)
  • Bundle budget — passing
  • CodeQL polynomial regex — replaced with linear scanning

Two non-blocking nits carried over (not adopted):

  1. initScriptLoader still eagerly imported in the client entry (thin budget headroom; a lazy import gated on nextData.scriptLoader would help)
  2. optimizeDeps.include now always set with React deps — safe but deserves a PR-body rationale

Verified correct: streaming shell-snapshot semantics, dedup, initScriptLoader ordering, client-side hoisted-script suppression + onReady-on-remount, span-stripping guards, and the data-nscript DOM contract.

Verdict: LGTM. Tests pass (102 + prod streaming + budget + 187), vp check clean.

github run

@james-elicx james-elicx marked this pull request as ready for review July 8, 2026 19:47
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.

2 participants