## 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#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>
* fix(app-router): hoist inline beforeInteractive scripts above resource hints (#1557)
React Fizz puts every stylesheet, modulepreload, and preload link emitted via React Float above user-rendered <script> children of <head>, so an inline `<Script strategy="beforeInteractive">` placed first in the root layout lands near the bottom of head instead — well after styles begin to parse. This breaks the standard no-flash dark-mode pattern.
Capture inline beforeInteractive content via a new BeforeInteractiveContext that the App Router SSR pipeline wraps the rendered tree with. The Script shim registers the escaped innerHTML + nonce + attributes and returns null from render. The stream transform splices the captured tag immediately after `<head ...>` opens — before any chunk that contains React-emitted resource hints. On the client the shim suppresses the inline render when the App Router runtime is present (detected via the new hasAppNavigationRuntimeBootstrap helper) so hydration matches.
External-src and non-App-Router rendering paths are unchanged: external scripts still preload + render in source order, and Pages Router falls through to the existing inline render.
* fix: address PR feedback
- e2e spec now reads `self.__vinextThemeInitRan` (matches the fixture's actual marker; the prior `documentElement.dataset` assertion would always have been undefined)
- gate `renderBeforeInteractiveInlineScripts` on a `/^[a-zA-Z][\w.-]*$/` attribute-name regex so unknown developer-supplied prop keys can't break out of the unquoted name position
- drop unused `BeforeInteractiveProvider` export — the SSR entry uses `BeforeInteractiveContext.Provider` directly
- correct the fixture layout docstring to describe the actual `self.__vinextThemeInitRan` marker and explain why we avoid `<html>` dataset attributes here
- consolidate the duplicate CSP-nonce middleware blocks for `/revalidate-test` and `/beforeinteractive-head-ordering`
* docs/test: address PR nit feedback
- correct the createTickBufferedTransform jsdoc: only `injectHTML` falls back to end-of-stream; `injectAfterHeadOpenHTML` is silently dropped when `<head ...>` is never found in a discoverable chunk (covered by the existing unit test)
- drop the dead pre-request scaffolding from the CSP-nonce test; the meaningful assertions live in the second request, so the first one was just `void html` noise