Skip to content

fix(script): register src beforeInteractive scripts and mark hoisted output - #2019

Merged
james-elicx merged 1 commit into
cloudflare:mainfrom
Xplod13:fix/script-before-interactive-src-registry
Jun 15, 2026
Merged

fix(script): register src beforeInteractive scripts and mark hoisted output#2019
james-elicx merged 1 commit into
cloudflare:mainfrom
Xplod13:fix/script-before-interactive-src-registry

Conversation

@Xplod13

@Xplod13 Xplod13 commented Jun 14, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Register src beforeInteractive next/scripts (not just inline ones) so they are hoisted into <head> server-side, matching inline behavior.
  • Mark every hoisted beforeInteractive script with data-nscript="beforeInteractive" and suppress the duplicate client render for both inline and src scripts, preventing double execution / hydration mismatch.
  • Map crossOrigincrossorigin and referrerPolicyreferrerpolicy when serializing hoisted script attributes.

Root Cause

Three defects in App Router beforeInteractive handling:

  1. src scripts bypassed the server registry. In shims/script.tsx the SSR branch computed inlineContent = src ? null : … and only registered when inlineContent !== null, so external src scripts were never captured and never hoisted into <head> before interactive — they stayed in source order behind React's resource hints. The condition now registers when src is present too.
  2. No hydration marker → duplicate execution. Hoisted scripts carried no marker, so the client React render of the same <Script> produced a second <script> node (double execution / hydration mismatch). Next.js marks server-rendered beforeInteractive scripts with data-nscript="beforeInteractive" and dedupes on the client; we now emit the same marker and return null on the client for both inline and src beforeInteractive scripts in App Router (afterInteractive/lazyOnload paths are unchanged).
  3. Attribute name mismatch. REACT_TO_HTML_ATTR lacked crossOrigin/referrerPolicy, so they round-tripped as camelCase; added the lowercase mappings to match Next.js' attribute serialization.

For testability the pure emit logic (renderBeforeInteractiveInlineScripts) was extracted from the virtual-import-heavy app-ssr-entry.ts into a new server/before-interactive-head.ts; attribute values are HTML-escaped at that boundary.

References

Verification

  • CI=true pnpm test tests/script.test.ts tests/script-head-ordering.test.ts — new tests cover: src beforeInteractive hoisted into <head>; crossOrigin/referrerPolicy lowercased on hoisted tags; client does not re-render a hoisted src script; every hoisted script carries data-nscript; multiple hoisted scripts (ported from Next.js). All green (red first).
  • CI=true pnpm test — full battery green (only the pre-existing env flakes deploy.test.ts > resolveWranglerBin and oxlint-prefer-shared-utils, reproduced identically on origin/main).
  • CI=true npx vp check — clean on all changed files.

@pkg-pr-new

pkg-pr-new Bot commented Jun 14, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@vinext/cloudflare@2019
npm i https://pkg.pr.new/vinext@2019

commit: f7eba35

@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

ask-bonk[bot]
ask-bonk Bot previously requested changes Jun 14, 2026

@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

The core fix is correct and matches Next.js parity well. The three defects are addressed cleanly:

  1. src beforeInteractive scripts now flow through BeforeInteractiveContext and get hoisted into <head> (the SSR registration condition (src || inlineContent !== null) and the client suppression condition mirror each other exactly — good).
  2. The data-nscript="beforeInteractive" marker is emitted server-side, and the client returns null for both inline and src scripts under the App Router runtime — Pages Router still renders inline since hasAppNavigationRuntimeBootstrap() is false. This is preserved correctly.
  3. crossOrigin/referrerPolicy lowercasing is added to REACT_TO_HTML_ATTR.

The extraction of renderBeforeInteractiveInlineScripts into server/before-interactive-head.ts aligns with the AGENTS.md guidance to keep entry/SSR modules thin and unit-test the helper directly. The duplicate-data-nscript guard and the innerHTML ?? "" handling for src-only scripts are both correct. I verified the fix propagates to the streaming path (app-ssr-stream.ts consumes the same injectAfterHeadOpenHTML), and tests/script.test.ts passes locally (36/36).

Blocking issue

Two broken symlinks were accidentally committed — they point to a machine-local macOS temp path (/var/folders/t4/...) and are dangling on any other machine (confirmed broken on Linux CI). They are leaked test fixtures created by tests/oxlint-prefer-shared-utils.test.ts (which normally cleans them up in afterEach) and must be removed before merge:

  • packages/vinext/src/__lint_rule_fixtures__-vinext-lint-rule-fixtures-kgEEgC
  • tests/__lint_rule_fixtures__-vinext-lint-rule-fixtures-kgEEgC

Neither exists on main; both are introduced by this PR. Please git rm them. It may also be worth adding __lint_rule_fixtures__-* to .gitignore to prevent this class of accidental commit in the future.

Everything else looks good.

@@ -0,0 +1 @@
/var/folders/t4/9xd5wqhx52d_3y9s8hstqk4w0000gn/T/vinext-lint-rule-fixtures-kgEEgC No newline at end of file

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.

Accidentally committed broken symlink. It points to a machine-local macOS temp path (/var/folders/t4/9xd5wqhx52d_3y9s8hstqk4w0000gn/T/...) and is dangling everywhere else. This is a leaked fixture from tests/oxlint-prefer-shared-utils.test.ts (createFixtureDir), which normally removes it in afterEach. Please git rm this file — it should not be in the repo.

@@ -0,0 +1 @@
/var/folders/t4/9xd5wqhx52d_3y9s8hstqk4w0000gn/T/vinext-lint-rule-fixtures-kgEEgC No newline at end of file

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.

Same broken symlink as the one under packages/vinext/src/. This is the test-side fixture link from writeTestFixture in tests/oxlint-prefer-shared-utils.test.ts. Please git rm it. Consider adding __lint_rule_fixtures__-* to .gitignore so a test interrupted mid-run can't leak these into a future commit.

@ask-bonk

ask-bonk Bot commented Jun 14, 2026

Copy link
Copy Markdown
Contributor

Review submitted successfully. Here's a summary of my findings:

Review Summary for PR #2019

Verdict: Changes requested (one blocking issue)

Core fix — correct ✅

The three defects are addressed cleanly and match Next.js behavior:

  1. src registrationsrc beforeInteractive scripts now flow through BeforeInteractiveContext and get hoisted into <head>. The SSR registration condition (src || inlineContent !== null) and the client suppression condition mirror each other exactly, which prevents drift between "what got hoisted" and "what the client suppresses."
  2. data-nscript marker + dedupe — emitted server-side; client returns null under the App Router runtime for both inline and src. Pages Router parity is preserved (renders inline since hasAppNavigationRuntimeBootstrap() is false).
  3. Attribute mappingcrossOrigin/referrerPolicy lowercasing added.

I verified:

  • The helper extraction (server/before-interactive-head.ts) aligns with AGENTS.md "keep entries thin" guidance.
  • The fix propagates to the streaming path (app-ssr-stream.ts uses the same injectAfterHeadOpenHTML).
  • The innerHTML ?? "" and duplicate-data-nscript guards are correct.
  • tests/script.test.ts passes locally (36/36).

Blocking issue ❌

Two broken symlinks were accidentally committed, pointing to a machine-local macOS temp path (/var/folders/t4/...) — dangling on any other machine (confirmed broken on Linux):

  • packages/vinext/src/__lint_rule_fixtures__-vinext-lint-rule-fixtures-kgEEgC
  • tests/__lint_rule_fixtures__-vinext-lint-rule-fixtures-kgEEgC

These are leaked fixtures from tests/oxlint-prefer-shared-utils.test.ts (normally cleaned up in afterEach). Neither exists on main. They need to be git rm'd, and I suggested adding __lint_rule_fixtures__-* to .gitignore to prevent recurrence.

github run

…output

## Summary
- Register `beforeInteractive` Scripts with `src` through
  BeforeInteractiveContext so they are hoisted into <head> ahead of
  interactive, exactly like the inline form (previously they bypassed the
  registry and rendered in source order, after React Float's head content).
- Suppress the client React render for both inline AND src beforeInteractive
  Scripts in the App Router so the hoisted tag is never duplicated.
- Tag every hoisted script with Next.js's `data-nscript="beforeInteractive"`
  marker.
- Map `crossOrigin` -> `crossorigin` and `referrerPolicy` -> `referrerpolicy`
  on hoisted scripts (parity with Next.js set-attributes-from-props).
- Extract `renderBeforeInteractiveInlineScripts` into a dedicated, pure module
  (before-interactive-head.ts) so the emit path is unit-testable.

## Root Cause
The beforeInteractive SSR branch computed `inlineContent = src ? null : ...`
and only registered when `inlineContent !== null`, so any Script with a `src`
was never captured by the registry and never spliced into <head>; it stayed
where the user wrote it, behind React Float's hoisted resource hints, defeating
the before-interactive guarantee. Hoisting src scripts then requires the client
to stop rendering them through React (the React-created <script> never executes
and would only add a duplicate DOM node), so the client dedupe condition is
aligned with the SSR registration condition. The hoisted output also lacked
Next.js's `data-nscript` marker, and REACT_TO_HTML_ATTR omitted
crossOrigin/referrerPolicy, so those attribute names did not match Next.js's
lowercased output.

## References
- Fixes cloudflare#2016
- Next.js: .nextjs-ref/packages/next/src/client/script.tsx (data-nscript,
  src/inline registry parity), set-attributes-from-props.ts (lowercased attrs)
- Ported: test/e2e/app-dir/script-before-interactive/script-before-interactive.test.ts

## Verification
- CI=true pnpm test tests/script.test.ts tests/script-head-ordering.test.ts
  tests/nextjs-compat/script-nonce.test.ts tests/shims.test.ts -> 1139 passed
- CI=true npx vp check on all changed files -> pass

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@Xplod13
Xplod13 force-pushed the fix/script-before-interactive-src-registry branch from 2553fa4 to f7eba35 Compare June 15, 2026 19:30
@Xplod13

Xplod13 commented Jun 15, 2026

Copy link
Copy Markdown
Contributor Author

Good catch — those two __lint_rule_fixtures__-* entries were leaked temp fixtures from tests/oxlint-prefer-shared-utils.test.ts (broken symlinks into a machine-local macOS temp path) that got swept into the commit by an over-broad git add. They have nothing to do with the fix.

Removed both with git rm and added __lint_rule_fixtures__-* to .gitignore so a crashed/interrupted run of that test can't commit them again. Pushed in f7eba352. The remaining diff is just the script-shim fix.

Thanks for verifying parity and the streaming path (app-ssr-stream.ts) too.

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

Review

The fix is correct, internally consistent, and well-tested. The previously-flagged blocking issue (two committed broken symlinks) has been resolved in f7eba35 — the file list no longer contains them and .gitignore now guards against recurrence. Nothing blocking remains.

Verified

  • SSR / client conditions mirror exactly. SSR registers when (src || inlineContent !== null) && registerBeforeInteractive (script.tsx:601); the client suppresses when (src || inlineContent !== null) && hasAppNavigationRuntimeBootstrap() (script.tsx:651). In the App Router SSR entry the BeforeInteractiveContext.Provider is always wired (app-ssr-entry.ts:440-444), and the App Router client always has the navigation runtime bootstrap, so "what got hoisted" and "what the client suppresses" stay in lockstep. Pages Router has no provider and no bootstrap, so both paths render inline — also consistent.
  • No client double-execution. The client useEffect early-returns for beforeInteractive after only inserting stylesheets (script.tsx:465-473); it never calls load(), so the server-hoisted <script src> is the sole loader.
  • Attribute mapping + escaping. crossOrigin/referrerPolicy are lowercased via REACT_TO_HTML_ATTR; nonce/src/id are RESERVED so they aren't double-emitted; values pass through escapeHtmlAttr; the data-nscript dedupe guard is correct.
  • Helper extraction into server/before-interactive-head.ts matches the AGENTS.md "keep entries thin" guidance and is now directly unit-tested.
  • tests/script.test.ts (36/36) and tests/script-head-ordering.test.ts (8/8) pass locally.

Parity note (non-blocking)

I checked the authoritative Next.js source (packages/next/src/client/script.tsx). In the App Router, Next.js does not emit a raw <script src data-nscript> into <head>. For both inline and src beforeInteractive scripts it renders a (self.__next_s=...).push([src|0, {...}]) queue entry; initScriptLoader/loadScript then creates the element, appends it to document.body, and applies el.setAttribute('data-nscript', strategy) at runtime. So the data-nscript marker is a client-applied DOM attribute on a body-injected element, not a server-hoisted head tag.

This PR continues vinext's pre-existing intentional divergence (hoisting a real tag into <head> for the no-flash pattern) and extends it from inline to src. That's a reasonable, self-consistent choice, but it is a deliberate divergence worth noting: a src script hoisted into <head> with neither async nor defer is render-blocking and executes in head order, whereas Next.js's runtime injection appends to <body>. Per AGENTS.md this kind of deliberate, documented divergence is acceptable — flagging it so it's a conscious decision rather than an accidental one.

Minor (optional)

Pages Router server-rendered beforeInteractive scripts (the buildBeforeInteractiveScriptProps path) still don't get the data-nscript="beforeInteractive" marker, whereas Next.js applies it in both routers. This is a pre-existing gap outside this PR's stated App Router scope, so it's fine to leave — just noting it for completeness.

LGTM once the parity divergence is acknowledged as intentional.

@ask-bonk

ask-bonk Bot commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

Review posted. Summary of my findings:

Verdict: Approve (no blocking issues)

The previously-flagged blocking issue — two committed broken symlinks pointing to a machine-local macOS temp path — has been resolved in f7eba35. They no longer appear in the PR file list, and .gitignore now guards against recurrence.

The fix is correct and well-tested

  1. SSR registration and client suppression conditions mirror exactly ((src || inlineContent !== null) on both sides), so hoisting and de-duplication stay in lockstep. The App Router SSR entry always wires the BeforeInteractiveContext.Provider, and the App Router client always has the navigation runtime, so the paths can't drift.
  2. No client double-execution — the useEffect early-returns for beforeInteractive and never calls load().
  3. Attribute mapping/escaping (crossOrigincrossorigin, referrerPolicyreferrerpolicy), the data-nscript dedupe guard, and reserved-key handling are all correct.
  4. Helper extraction aligns with the "keep entries thin" guidance. Both targeted test files pass locally (36/36 and 8/8).

One non-blocking parity note

I checked the authoritative Next.js source: in the App Router, Next.js does not hoist a raw <script src data-nscript> into <head> — it queues (self.__next_s=...).push([...]) and the client runtime injects the element into <body>, applying data-nscript at runtime. This PR continues vinext's pre-existing intentional divergence (head-hoisting for the no-flash pattern) and extends it to src scripts. That's reasonable and self-consistent, but I flagged it so it's recognized as a deliberate divergence (a render-blocking head <script src> vs. Next.js's body injection), per AGENTS.md's requirement that divergences be conscious rather than accidental.

I also noted a minor pre-existing gap (Pages Router beforeInteractive scripts lack the data-nscript marker) that is outside this PR's stated App Router scope.

github run

@james-elicx
james-elicx merged commit 33be486 into cloudflare:main Jun 15, 2026
44 checks passed
@Xplod13
Xplod13 deleted the fix/script-before-interactive-src-registry branch June 16, 2026 17:09
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.

next/script beforeInteractive: hydration duplicate, src bypasses server registry, attr-map gaps

2 participants