Emit the Turbopack shared-runtime bootstrap in the PPR resume so it gets the CSP nonce

Merged
SO
5d ago
+638-72

What?

With cacheComponents and the default experimental.turbopackSharedRuntime, a Turbopack production build breaks hydration under a nonce-based CSP (script-src 'self' 'nonce-…'). The route's inlined chunk-group bootstrap ends up in the prerendered static shell without a nonce. The browser blocks it, the Turbopack runtime never starts the entry, and the page stays non-interactive.

This PR emits that bootstrap from the per-request part of the response instead, where the request's nonce is known. The shared runtime stays on by default.

Why?

Since 16.4 the shared runtime replaces the per-route evaluate chunk with an inline globalThis.TURBOPACK.push({ otherChunks, runtimeModuleIds }) script, passed to React as bootstrapScriptContent. A PPR static shell is rendered at build time: there is no request, so React has no nonce to put on it.

The shell can't be fixed when it's served either. On Vercel the proxy serves the cached shell before any Next.js code runs, and only the resumed dynamic stream is rendered per request. So the bootstrap has to come from that stream.

How?

Server. Cache Components prerenders now keep the Turbopack bootstrap separate from the other bootstrap content, such as the Instant Navigation testing script, and leave it out of the shell.

  • For a postponed shell, the resume emits it as an inline script with the request nonce, right before the inlined Flight data. This reuses the existing data-injection pipeline, so the ordering is the same as today: after the first HTML chunk for empty preludes, and for HTML- and data-postponed state on both the Node and web stream paths.
  • A complete prerender emits it the same way, as part of the stored response.
  • Fully dynamic renders and non-Cache-Components prerenders keep using React's bootstrapScriptContent.
  • The paths that serve a postponed shell without a resume (the Instant Navigation testing lock and __nextppronly shell debugging) append the bootstrap themselves.

Client. A bootstrap pushed after the shared runtime has loaded is registered from inside that inline script. There, document.currentScript has no src, so appBootstrap() threw Invalid URL while deriving the asset prefix. That prefix is only used by the dev server's HMR WebSocket, so it's now only derived on the dev server. Dev behavior is unchanged.

Not covered:

  • React's own $RT timing script in the static shell still has no nonce, so a nonce CSP still reports that one violation. It doesn't affect hydration, and it was already blocked before 16.4.
  • Fully static pages under a nonce CSP are unchanged; their inline Flight data has no nonce either.

Tests

New e2e suite test/e2e/app-dir/ppr-shared-runtime-csp-nonce. It runs on Turbopack production builds only (@force-gate !dev && turbopack) and covers:

  • the shell contains no bootstrap, and the resume contains it exactly once with the request nonce
  • the nonce is also picked up from a report-only CSP, and there is no nonce without a CSP
  • the page hydrates under a nonce CSP
  • the page hydrates when the bootstrap runs after the shared runtime and all entry chunks have loaded. The test forces that ordering deterministically, and it fails without the client change.
  • fully static pages keep the inline bootstrap and hydrate

It fails on canary because the bootstrap is in the shell without a nonce and the page doesn't hydrate.

Existing suites run to check behavior didn't change:

  • Turbopack start: instant-navigation-testing-api (this suite caught the late-bootstrap Invalid URL issue), ppr-partial-hydration, partial-fallback-root-blocking, partial-fallback-shell-upgrade, csp-nonce-segment-scripts, next-dynamic-csp-nonce, static-shell-debugging
  • Webpack start: csp-nonce-segment-scripts
  • Dev (Turbopack): csp-nonce-segment-scripts

I also checked by hand with the issue's reproduction (nonce CSP from proxy.ts): the page hydrates and only the $RT violation remains.

Not run: Vercel deploy and edge-runtime tests.

Fixes #99800