vercel/next.js · #97570
[no-unsafe-inline] Add an experimental flag for React's external browser runtime
crates/next-api/src/app.rs34 + / 0 −
@@ -1456,6 +1456,39 @@ impl AppEndpoint { None }; + // Like the polyfill, this is a pre-compiled asset distributed as part of next and served+ // as-is rather than bundled with the framework. Keeping it small and independent lets it+ // reveal streamed Suspense boundaries before the framework loads or hydration begins.+ // React only publishes it on the experimental channel, which+ // `experimental.externalBrowserRuntime` opts into.+ let external_browser_runtime_output_asset =+ if matches!(this.ty, AppEndpointType::Page { .. })+ && *project+ .next_config()+ .enable_external_browser_runtime()+ .await?+ {+ let next_package = get_next_package(project.project_path().owned().await?).await?;+ let external_browser_runtime_source = FileSource::new(next_package.join(+ "dist/compiled/react-dom-experimental/unstable_server-external-runtime.js",+ )?);++ let external_browser_runtime_output_asset = ResolvedVc::upcast(+ SingleFileEcmascriptOutput::new(+ *client_chunking_context,+ Vc::upcast(external_browser_runtime_source),+ )+ .to_resolved()+ .await?,+ );++ client_assets.insert(external_browser_runtime_output_asset);++ Some(external_browser_runtime_output_asset)+ } else {+ None+ };+ // Compile any service workers registered via `navigator.serviceWorker.register(new // URL(...), { scope })` reachable from this endpoint. client_assets.extend(@@ -1490,6 +1523,7 @@ impl AppEndpoint { pages: Default::default(), root_main_files: client_shared_chunks, polyfill_files: polyfill_output_asset.into_iter().collect(),+ external_browser_runtime_file: external_browser_runtime_output_asset, root_main_files_per_page, pages_chunk_group_bootstrap_params: client_chunk_group_bootstrap_params .map(|params| {crates/next-api/src/pages.rs2 + / 0 −
@@ -1443,6 +1443,8 @@ impl PageEndpoint { client_relative_path, pages, polyfill_files: Default::default(),+ // App Router only for now; the Pages Router still inlines React's instructions.+ external_browser_runtime_file: None, root_main_files: Default::default(), root_main_files_per_page: Default::default(), pages_chunk_group_bootstrap_params,crates/next-core/src/next_config.rs11 + / 1 −
@@ -1179,6 +1179,9 @@ pub struct ExperimentalConfig { // rename explicitly so it deserializes from the public `blockingSSR` field. #[serde(rename = "blockingSSR")] blocking_ssr: Option<bool>,+ /// Streams React's instruction set as data plus a standalone browser runtime+ /// asset instead of inline `<script>` tags.+ external_browser_runtime: Option<bool>, /// @internal Used by the Next.js internals only. trust_host_header: Option<bool>, @@ -2202,6 +2205,11 @@ impl NextConfig { Vc::cell(self.experimental.taint.unwrap_or(false)) } + #[turbo_tasks::function]+ pub fn enable_external_browser_runtime(&self) -> Vc<bool> {+ Vc::cell(self.experimental.external_browser_runtime.unwrap_or(false))+ }+ #[turbo_tasks::function] pub fn enable_expose_testing_api_in_production_build(&self) -> Vc<bool> { Vc::cell(@@ -2270,12 +2278,14 @@ impl NextConfig { let transition_indicator = self.experimental.transition_indicator.unwrap_or(false); let gesture_transition = self.experimental.gesture_transition.unwrap_or(false); let hydration_change_event = self.experimental.hydration_change_event.unwrap_or(false);+ let external_browser_runtime = self.experimental.external_browser_runtime.unwrap_or(false); Vc::cell( blocking_ssr || taint || transition_indicator || gesture_transition- || hydration_change_event,+ || hydration_change_event+ || external_browser_runtime, ) } crates/next-core/src/next_manifests/mod.rs23 + / 0 −
@@ -31,6 +31,9 @@ pub struct BuildManifest { pub client_relative_path: FileSystemPath, pub polyfill_files: Vec<ResolvedVc<Box<dyn OutputAsset>>>,+ /// The standalone browser runtime that applies React's instruction set when+ /// `experimental.externalBrowserRuntime` is enabled. `None` otherwise.+ pub external_browser_runtime_file: Option<ResolvedVc<Box<dyn OutputAsset>>>, pub root_main_files: Vec<ResolvedVc<Box<dyn OutputAsset>>>, #[bincode(with = "turbo_bincode::indexmap")] pub pages: FxIndexMap<RcStr, ResolvedVc<OutputAssets>>,@@ -73,6 +76,7 @@ impl OutputAssetsReference for BuildManifest { .flatten() .chain(root_main_files) .chain(self.polyfill_files.iter().copied())+ .chain(self.external_browser_runtime_file) .chain(per_page_files) .collect(); @@ -102,6 +106,8 @@ impl Asset for BuildManifest { pub dev_files: Vec<RcStr>, pub amp_dev_files: Vec<RcStr>, pub polyfill_files: Vec<RcStr>,+ #[serde(skip_serializing_if = "Option::is_none")]+ pub external_browser_runtime_file: Option<RcStr>, pub low_priority_files: Vec<RcStr>, pub root_main_files: Vec<RcStr>, pub pages: FxIndexMap<RcStr, Vec<RcStr>>,@@ -150,6 +156,22 @@ impl Asset for BuildManifest { .try_join() .await?; + let external_browser_runtime_file: Option<RcStr> = match self.external_browser_runtime_file+ {+ Some(chunk) => {+ let chunk_path = chunk.path().await?;+ Some(+ client_relative_path+ .get_path_to(&chunk_path)+ .context(+ "failed to resolve client-relative path to external browser runtime",+ )?+ .into(),+ )+ }+ None => None,+ };+ let root_main_files: Vec<RcStr> = self .root_main_files .iter()@@ -202,6 +224,7 @@ impl Asset for BuildManifest { let manifest = SerializedBuildManifest { pages: FxIndexMap::from_iter(pages), polyfill_files,+ external_browser_runtime_file, root_main_files, root_main_files_tree: FxIndexMap::from_iter(root_main_files_tree), pages_chunk_group_bootstrap_params: selfpackages/next/src/build/templates/app-page-runtime.ts2 + / 0 −
@@ -1100,6 +1100,8 @@ export function createAppPageEntrypoint({ nextConfig.experimental.disableResumeDataCacheCompression ?? false, exposeTestingApi,+ externalBrowserRuntime:+ nextConfig.experimental.externalBrowserRuntime === true, }, waitUntil: ctx.waitUntil,packages/next/src/build/templates/edge-ssr-app.ts2 + / 0 −
@@ -225,6 +225,8 @@ async function requestHandler( nextConfig.cacheComponents === true && (pageRouteModule.isDev === true || nextConfig.experimental.exposeTestingApiInProductionBuild === true),+ externalBrowserRuntime:+ nextConfig.experimental.externalBrowserRuntime === true, }, incrementalCache: await pageRouteModule.getIncrementalCache(packages/next/src/build/webpack-config.ts25 + / 0 −
@@ -19,6 +19,8 @@ import { } from './utils' import type { CustomRoutes } from '../lib/load-custom-routes.js' import {+ CLIENT_STATIC_FILES_RUNTIME_EXTERNAL_BROWSER_RUNTIME,+ CLIENT_STATIC_FILES_RUNTIME_EXTERNAL_BROWSER_RUNTIME_SYMBOL, CLIENT_STATIC_FILES_RUNTIME_MAIN, CLIENT_STATIC_FILES_RUNTIME_MAIN_APP, CLIENT_STATIC_FILES_RUNTIME_POLYFILLS_SYMBOL,@@ -2171,6 +2173,29 @@ export default async function getBaseWebpackConfig( minimized: true, }, }),+ isClient &&+ hasAppDir &&+ config.experimental.externalBrowserRuntime === true &&+ new CopyFilePlugin({+ // React only publishes this standalone runtime on the experimental+ // channel, which `experimental.externalBrowserRuntime` opts into via+ // `needsExperimentalReact`. Keep it small and independent of the+ // framework bundle so it can reveal streamed Suspense boundaries+ // before the framework loads or hydration begins.+ filePath: require.resolve(+ '../compiled/react-dom-experimental/unstable_server-external-runtime'+ ),+ cacheKey: process.env.__NEXT_VERSION as string,+ name: `static/chunks/${CLIENT_STATIC_FILES_RUNTIME_EXTERNAL_BROWSER_RUNTIME}${+ dev ? '' : '-[hash]'+ }.js`,+ minimize: false,+ info: {+ [CLIENT_STATIC_FILES_RUNTIME_EXTERNAL_BROWSER_RUNTIME_SYMBOL]: 1,+ // This file is already minified+ minimized: true,+ },+ }), hasAppDir && (isClient ? new ClientReferenceManifestPlugin({packages/next/src/build/webpack/plugins/build-manifest-plugin.ts10 + / 0 −
@@ -6,6 +6,7 @@ import { BUILD_MANIFEST, MIDDLEWARE_BUILD_MANIFEST, CLIENT_STATIC_FILES_PATH,+ CLIENT_STATIC_FILES_RUNTIME_EXTERNAL_BROWSER_RUNTIME_SYMBOL, CLIENT_STATIC_FILES_RUNTIME_MAIN, CLIENT_STATIC_FILES_RUNTIME_MAIN_APP, CLIENT_STATIC_FILES_RUNTIME_POLYFILLS_SYMBOL,@@ -182,6 +183,15 @@ export default class BuildManifestPlugin { }) .map((v) => v.name) + // `CopyFilePlugin` emits at most one of these, and only when+ // `experimental.externalBrowserRuntime` is enabled.+ assetMap.externalBrowserRuntimeFile = compilationAssets.find(+ (p) =>+ p.name.endsWith('.js') &&+ p.info &&+ CLIENT_STATIC_FILES_RUNTIME_EXTERNAL_BROWSER_RUNTIME_SYMBOL in p.info+ )?.name+ assetMap.devFiles = getEntrypointFiles( entrypoints.get(CLIENT_STATIC_FILES_RUNTIME_REACT_REFRESH) ).filter((file) => !mainFiles.has(file))packages/next/src/cli/internal/static-routes-info.ts8 + / 3 −
@@ -598,10 +598,15 @@ function collectAppClientFiles( // app-page. Both bundlers list them in the global `build-manifest.json` // under `rootMainFiles`. Turbopack also writes a per-route // `build-manifest.json` containing the same files; webpack does not.- const globalBm = readJsonFile<{ rootMainFiles?: string[] }>(- path.join(distDir, 'build-manifest.json')- )+ const globalBm = readJsonFile<{+ rootMainFiles?: string[]+ externalBrowserRuntimeFile?: string+ }>(path.join(distDir, 'build-manifest.json')) for (const chunk of globalBm?.rootMainFiles ?? []) addClientChunk(chunk, sets)+ // Present only when `experimental.externalBrowserRuntime` is enabled.+ if (globalBm?.externalBrowserRuntimeFile) {+ addClientChunk(globalBm.externalBrowserRuntimeFile, sets)+ } } /**packages/next/src/compiled/react-dom-experimental/unstable_server-external-runtime.jsadded504 + / 0 −
@@ -0,0 +1,504 @@+(function () {+ function handleExistingNodes(target) {+ target = target.querySelectorAll("template");+ for (var i = 0; i < target.length; i++) handleNode(target[i]);+ }+ function installFizzInstrObserver(target) {+ function handleMutations(mutations) {+ for (var i = 0; i < mutations.length; i++)+ for (+ var addedNodes = mutations[i].addedNodes, j = 0;+ j < addedNodes.length;+ j+++ )+ addedNodes[j].parentNode && handleNode(addedNodes[j]);+ }+ var fizzInstrObserver = new MutationObserver(handleMutations);+ fizzInstrObserver.observe(target, { childList: !0 });+ window.addEventListener("DOMContentLoaded", function () {+ handleMutations(fizzInstrObserver.takeRecords());+ fizzInstrObserver.disconnect();+ });+ }+ function handleNode(node_) {+ if (1 === node_.nodeType && node_.dataset) {+ var dataset = node_.dataset;+ null != dataset.rxi+ ? ($RX(+ dataset.bid,+ dataset.dgst,+ dataset.msg,+ dataset.stck,+ dataset.cstck+ ),+ node_.remove())+ : null != dataset.rri+ ? ($RR(dataset.bid, dataset.sid, JSON.parse(dataset.sty)),+ node_.remove())+ : null != dataset.rci+ ? ($RC(dataset.bid, dataset.sid), node_.remove())+ : null != dataset.rsi &&+ ($RS(dataset.sid, dataset.pid), node_.remove());+ }+ }+ var $RT;+ var $RM = new Map();+ var $RB = [];+ var $RX = function (+ suspenseBoundaryID,+ errorDigest,+ errorMsg,+ errorStack,+ errorComponentStack+ ) {+ var suspenseIdNode = document.getElementById(suspenseBoundaryID);+ suspenseIdNode &&+ ((suspenseBoundaryID = suspenseIdNode.previousSibling),+ (suspenseBoundaryID.data = "$!"),+ (suspenseIdNode = suspenseIdNode.dataset),+ null != errorDigest && (suspenseIdNode.dgst = errorDigest),+ errorMsg && (suspenseIdNode.msg = errorMsg),+ errorStack && (suspenseIdNode.stck = errorStack),+ errorComponentStack && (suspenseIdNode.cstck = errorComponentStack),+ suspenseBoundaryID._reactRetry && suspenseBoundaryID._reactRetry());+ };+ var $RV = function (revealBoundaries, batch) {+ function applyViewTransitionName(element, classAttributeName) {+ var className = element.getAttribute(classAttributeName);+ className &&+ ((classAttributeName = element.style),+ restoreQueue.push(+ element,+ classAttributeName.viewTransitionName,+ classAttributeName.viewTransitionClass+ ),+ "auto" !== className &&+ (classAttributeName.viewTransitionClass = className),+ (element = element.getAttribute("vt-name")) ||+ (element = "_T_" + autoNameIdx++ + "_"),+ (element =+ CSS.escape(element) !== element+ ? "r-" + btoa(element).replace(/=/g, "")+ : element),+ (classAttributeName.viewTransitionName = element),+ (shouldStartViewTransition = !0));+ }+ var shouldStartViewTransition = !1,+ autoNameIdx = 0,+ restoreQueue = [];+ try {+ var existingTransition = document.__reactViewTransition;+ if (existingTransition) {+ existingTransition.finished.finally($RV.bind(null, batch));+ return;+ }+ var appearingViewTransitions = new Map();+ for (+ existingTransition = 1;+ existingTransition < batch.length;+ existingTransition += 2+ )+ for (+ var appearingElements =+ batch[existingTransition].querySelectorAll("[vt-share]"),+ j = 0;+ j < appearingElements.length;+ j+++ ) {+ var appearingElement = appearingElements[j];+ appearingViewTransitions.set(+ appearingElement.getAttribute("vt-name"),+ appearingElement+ );+ }+ var suspenseyImages = [];+ for (+ appearingElements = 0;+ appearingElements < batch.length;+ appearingElements += 2+ ) {+ var suspenseIdNode = batch[appearingElements],+ parentInstance = suspenseIdNode.parentNode;+ if (parentInstance) {+ var parentRect = parentInstance.getBoundingClientRect();+ if (+ parentRect.left ||+ parentRect.top ||+ parentRect.width ||+ parentRect.height+ ) {+ appearingElement = suspenseIdNode;+ for (existingTransition = 0; appearingElement; ) {+ if (8 === appearingElement.nodeType) {+ var data = appearingElement.data;+ if ("/$" === data)+ if (0 === existingTransition) break;+ else existingTransition--;+ else+ ("$" !== data &&+ "$?" !== data &&+ "$~" !== data &&+ "$!" !== data) ||+ existingTransition++;+ } else if (1 === appearingElement.nodeType) {+ j = appearingElement;+ var exitName = j.getAttribute("vt-name"),+ pairedElement = appearingViewTransitions.get(exitName);+ applyViewTransitionName(+ j,+ pairedElement ? "vt-share" : "vt-exit"+ );+ pairedElement &&+ (applyViewTransitionName(pairedElement, "vt-share"),+ appearingViewTransitions.set(exitName, null));+ for (+ var disappearingElements = j.querySelectorAll("[vt-share]"),+ j$1 = 0;+ j$1 < disappearingElements.length;+ j$1+++ ) {+ var disappearingElement = disappearingElements[j$1],+ name = disappearingElement.getAttribute("vt-name"),+ appearingElement$2 = appearingViewTransitions.get(name);+ appearingElement$2 &&+ (applyViewTransitionName(disappearingElement, "vt-share"),+ applyViewTransitionName(appearingElement$2, "vt-share"),+ appearingViewTransitions.set(name, null));+ }+ var relayExitElements = j.querySelectorAll("[vt-parent-exit]");+ for (j = 0; j < relayExitElements.length; j++)+ applyViewTransitionName(+ relayExitElements[j],+ "vt-parent-exit"+ );+ }+ appearingElement = appearingElement.nextSibling;+ }+ for (+ var contentNode$4 = batch[appearingElements + 1],+ enterElement = contentNode$4.firstElementChild;+ enterElement;++ ) {+ null !==+ appearingViewTransitions.get(+ enterElement.getAttribute("vt-name")+ ) && applyViewTransitionName(enterElement, "vt-enter");+ var relayEnterElements =+ enterElement.querySelectorAll("[vt-parent-enter]");+ for (+ appearingElement = 0;+ appearingElement < relayEnterElements.length;+ appearingElement+++ )+ applyViewTransitionName(+ relayEnterElements[appearingElement],+ "vt-parent-enter"+ );+ enterElement = enterElement.nextElementSibling;+ }+ appearingElement = parentInstance;+ do+ for (+ var childElement = appearingElement.firstElementChild;+ childElement;++ ) {+ var updateClassName = childElement.getAttribute("vt-update");+ updateClassName &&+ "none" !== updateClassName &&+ !restoreQueue.includes(childElement) &&+ applyViewTransitionName(childElement, "vt-update");+ childElement = childElement.nextElementSibling;+ }+ while (+ (appearingElement = appearingElement.parentNode) &&+ 1 === appearingElement.nodeType &&+ "none" !== appearingElement.getAttribute("vt-update")+ );+ var appearingImages = contentNode$4.querySelectorAll(+ 'img[src]:not([loading="lazy"])'+ );+ suspenseyImages.push.apply(suspenseyImages, appearingImages);+ }+ }+ }+ if (shouldStartViewTransition) {+ var transition = (document.__reactViewTransition =+ document.startViewTransition({+ update: function () {+ revealBoundaries(batch);+ for (+ var blockingPromises = [+ document.documentElement.clientHeight,+ document.fonts.ready+ ],+ $jscomp$loop$9 = {},+ i$6 = 0;+ i$6 < suspenseyImages.length;+ $jscomp$loop$9 = {+ $jscomp$loop$prop$suspenseyImage$10:+ $jscomp$loop$9.$jscomp$loop$prop$suspenseyImage$10+ },+ i$6+++ )+ if (+ (($jscomp$loop$9.$jscomp$loop$prop$suspenseyImage$10 =+ suspenseyImages[i$6]),+ !$jscomp$loop$9.$jscomp$loop$prop$suspenseyImage$10.complete)+ ) {+ var rect =+ $jscomp$loop$9.$jscomp$loop$prop$suspenseyImage$10.getBoundingClientRect();+ 0 < rect.bottom &&+ 0 < rect.right &&+ rect.top < window.innerHeight &&+ rect.left < window.innerWidth &&+ ((rect = new Promise(+ (function ($jscomp$loop$9) {+ return function (resolve) {+ $jscomp$loop$9.$jscomp$loop$prop$suspenseyImage$10.addEventListener(+ "load",+ resolve+ );+ $jscomp$loop$9.$jscomp$loop$prop$suspenseyImage$10.addEventListener(+ "error",+ resolve+ );+ };+ })($jscomp$loop$9)+ )),+ blockingPromises.push(rect));+ }+ return Promise.race([+ Promise.all(blockingPromises),+ new Promise(function (resolve) {+ var currentTime = performance.now();+ setTimeout(+ resolve,+ 2300 > currentTime && 2e3 < currentTime+ ? 2300 - currentTime+ : 500+ );+ })+ ]);+ },+ types: []+ }));+ transition.ready.finally(function () {+ for (var i$7 = restoreQueue.length - 3; 0 <= i$7; i$7 -= 3) {+ var element = restoreQueue[i$7],+ elementStyle = element.style;+ elementStyle.viewTransitionName = restoreQueue[i$7 + 1];+ elementStyle.viewTransitionClass = restoreQueue[i$7 + 1];+ "" === element.getAttribute("style") &&+ element.removeAttribute("style");+ }+ });+ transition.finished.finally(function () {+ document.__reactViewTransition === transition &&+ (document.__reactViewTransition = null);+ });+ $RB = [];+ return;+ }+ } catch (x) {}+ revealBoundaries(batch);+ }.bind(null, function (batch) {+ $RT = performance.now();+ for (var i = 0; i < batch.length; i += 2) {+ var suspenseIdNode = batch[i],+ contentNode = batch[i + 1];+ null !== contentNode.parentNode &&+ contentNode.parentNode.removeChild(contentNode);+ var parentInstance = suspenseIdNode.parentNode;+ if (parentInstance) {+ var suspenseNode = suspenseIdNode.previousSibling,+ depth = 0;+ do {+ if (suspenseIdNode && 8 === suspenseIdNode.nodeType) {+ var data = suspenseIdNode.data;+ if ("/$" === data || "/&" === data)+ if (0 === depth) break;+ else depth--;+ else+ ("$" !== data &&+ "$?" !== data &&+ "$~" !== data &&+ "$!" !== data &&+ "&" !== data) ||+ depth++;+ }+ data = suspenseIdNode.nextSibling;+ parentInstance.removeChild(suspenseIdNode);+ suspenseIdNode = data;+ } while (suspenseIdNode);+ for (; contentNode.firstChild; )+ parentInstance.insertBefore(contentNode.firstChild, suspenseIdNode);+ suspenseNode.data = "$";+ suspenseNode._reactRetry &&+ requestAnimationFrame(suspenseNode._reactRetry);+ }+ }+ batch.length = 0;+ });+ var $RC = function (suspenseBoundaryID, contentID) {+ if ((contentID = document.getElementById(contentID)))+ (suspenseBoundaryID = document.getElementById(suspenseBoundaryID))+ ? ((suspenseBoundaryID.previousSibling.data = "$~"),+ $RB.push(suspenseBoundaryID, contentID),+ 2 === $RB.length &&+ ("number" !== typeof $RT+ ? requestAnimationFrame($RV.bind(null, $RB))+ : ((suspenseBoundaryID = performance.now()),+ (suspenseBoundaryID =+ 2300 > suspenseBoundaryID && 2e3 < suspenseBoundaryID+ ? 2300 - suspenseBoundaryID+ : $RT + 300 - suspenseBoundaryID),+ setTimeout($RV.bind(null, $RB), suspenseBoundaryID))))+ : contentID.parentNode.removeChild(contentID);+ };+ var $RR = function (suspenseBoundaryID, contentID, stylesheetDescriptors) {+ function cleanupWith(cb) {+ this._p = null;+ cb();+ }+ for (+ var precedences = new Map(),+ thisDocument = document,+ lastResource,+ node,+ nodes = thisDocument.querySelectorAll(+ "link[data-precedence],style[data-precedence]"+ ),+ styleTagsToHoist = [],+ i$8 = 0;+ (node = nodes[i$8++]);++ )+ "not all" === node.getAttribute("media")+ ? styleTagsToHoist.push(node)+ : ("LINK" === node.tagName && $RM.set(node.getAttribute("href"), node),+ precedences.set(node.dataset.precedence, (lastResource = node)));+ nodes = 0;+ node = [];+ var precedence, resourceEl;+ for (i$8 = !0; ; ) {+ if (i$8) {+ var stylesheetDescriptor = stylesheetDescriptors[nodes++];+ if (!stylesheetDescriptor) {+ i$8 = !1;+ nodes = 0;+ continue;+ }+ var avoidInsert = !1,+ j = 0;+ var href = stylesheetDescriptor[j++];+ if ((resourceEl = $RM.get(href))) {+ var attr = resourceEl._p;+ avoidInsert = !0;+ } else {+ resourceEl = thisDocument.createElement("link");+ resourceEl.href = href;+ resourceEl.rel = "stylesheet";+ for (+ resourceEl.dataset.precedence = precedence =+ stylesheetDescriptor[j++];+ (attr = stylesheetDescriptor[j++]);++ )+ resourceEl.setAttribute(attr, stylesheetDescriptor[j++]);+ attr = resourceEl._p = new Promise(function (resolve, reject) {+ resourceEl.onload = cleanupWith.bind(resourceEl, resolve);+ resourceEl.onerror = cleanupWith.bind(resourceEl, reject);+ });+ $RM.set(href, resourceEl);+ }+ href = resourceEl.getAttribute("media");+ !attr || (href && !window.matchMedia(href).matches) || node.push(attr);+ if (avoidInsert) continue;+ } else {+ resourceEl = styleTagsToHoist[nodes++];+ if (!resourceEl) break;+ precedence = resourceEl.getAttribute("data-precedence");+ resourceEl.removeAttribute("media");+ }+ avoidInsert = precedences.get(precedence) || lastResource;+ avoidInsert === lastResource && (lastResource = resourceEl);+ precedences.set(precedence, resourceEl);+ avoidInsert+ ? avoidInsert.parentNode.insertBefore(+ resourceEl,+ avoidInsert.nextSibling+ )+ : ((avoidInsert = thisDocument.head),+ avoidInsert.insertBefore(resourceEl, avoidInsert.firstChild));+ }+ if ((stylesheetDescriptors = document.getElementById(suspenseBoundaryID)))+ stylesheetDescriptors.previousSibling.data = "$~";+ Promise.all(node).then(+ $RC.bind(null, suspenseBoundaryID, contentID),+ $RX.bind(null, suspenseBoundaryID, "CSS failed to load")+ );+ };+ var $RS = function (containerID, placeholderID) {+ containerID = document.getElementById(containerID);+ placeholderID = document.getElementById(placeholderID);+ for (+ containerID.parentNode.removeChild(containerID);+ containerID.firstChild;++ )+ placeholderID.parentNode.insertBefore(+ containerID.firstChild,+ placeholderID+ );+ placeholderID.parentNode.removeChild(placeholderID);+ };+ (function () {+ addEventListener("submit", function (event) {+ if (!event.defaultPrevented) {+ var form = event.target,+ submitter = event.submitter,+ action = form.action,+ formDataSubmitter = submitter;+ if (submitter) {+ var submitterAction = submitter.getAttribute("formAction");+ null != submitterAction &&+ ((action = submitterAction), (formDataSubmitter = null));+ }+ "javascript:throw new Error('React form unexpectedly submitted.')" ===+ action &&+ (event.preventDefault(),+ (event = new FormData(form, formDataSubmitter)),+ (action = form.ownerDocument || form),+ (action.$$reactFormReplay = action.$$reactFormReplay || []).push(+ form,+ submitter,+ event+ ));+ }+ });+ })();+ var entries = performance.getEntriesByType+ ? performance.getEntriesByType("paint")+ : [];+ 0 < entries.length+ ? ($RT = entries[0].startTime)+ : requestAnimationFrame(function () {+ $RT = performance.now();+ });+ if (null != document.body)+ "loading" === document.readyState &&+ installFizzInstrObserver(document.body),+ handleExistingNodes(document.body);+ else {+ var domBodyObserver = new MutationObserver(function () {+ null != document.body &&+ ("loading" === document.readyState &&+ installFizzInstrObserver(document.body),+ handleExistingNodes(document.body),+ domBodyObserver.disconnect());+ });+ domBodyObserver.observe(document.documentElement, { childList: !0 });+ }+})();packages/next/src/export/index.ts2 + / 0 −
@@ -532,6 +532,8 @@ async function exportAppImpl( nextConfig.experimental.disableResumeDataCacheCompression ?? false, exposeTestingApi: nextConfig.experimental.exposeTestingApiInProductionBuild === true,+ externalBrowserRuntime:+ nextConfig.experimental.externalBrowserRuntime === true, }, reactMaxHeadersLength: nextConfig.reactMaxHeadersLength, }packages/next/src/lib/needs-experimental-react.ts3 + / 1 −
@@ -8,12 +8,14 @@ export function needsExperimentalReact(config: NextConfig) { transitionIndicator, gestureTransition, hydrationChangeEvent,+ externalBrowserRuntime, } = config.experimental || {} return Boolean( blockingSSR || taint || transitionIndicator || gestureTransition ||- hydrationChangeEvent+ hydrationChangeEvent ||+ externalBrowserRuntime ) }packages/next/src/server/app-render/app-render.tsx28 + / 0 −
@@ -152,6 +152,7 @@ import { } from './walk-tree-with-flight-router-state' import { createFullComponentTree, getRootParams } from './create-component-tree' import { getAssetQueryString } from './get-asset-query-string'+import { getExternalBrowserRuntimeSrc } from './get-external-browser-runtime-src' import { getClientReferenceManifest, getServerModuleMap,@@ -3596,6 +3597,15 @@ async function renderToStream( nonce, })) + const externalRuntimeSrc = getExternalBrowserRuntimeSrc(+ ctx,+ buildManifest,+ experimental.externalBrowserRuntime,+ assetPrefix,+ crossOrigin,+ subresourceIntegrityManifest+ )+ const [preinitScripts, bootstrapScript] = getRequiredScripts( buildManifest, // Why is assetPrefix optional on renderOpts?@@ -4219,6 +4229,7 @@ async function renderToStream( maxHeadersLength: reactMaxHeadersLength, bootstrapScriptContent, bootstrapScripts: [bootstrapScript],+ unstable_externalRuntimeSrc: externalRuntimeSrc, formState, } @@ -4360,6 +4371,7 @@ async function renderToStream( maxHeadersLength: reactMaxHeadersLength, bootstrapScriptContent, bootstrapScripts: [bootstrapScript],+ unstable_externalRuntimeSrc: externalRuntimeSrc, formState, } @@ -4524,6 +4536,7 @@ async function renderToStream( nonce, bootstrapScriptContent: errorBootstrapScriptContent, bootstrapScripts: [errorBootstrapScript],+ unstable_externalRuntimeSrc: externalRuntimeSrc, formState, onAllReady: finishHtmlRender, },@@ -4617,6 +4630,7 @@ async function renderToStream( nonce, bootstrapScriptContent: errorBootstrapScriptContent, bootstrapScripts: [errorBootstrapScript],+ unstable_externalRuntimeSrc: externalRuntimeSrc, formState, } )@@ -8995,6 +9009,15 @@ async function prerenderToStream( nonce, })) + const externalRuntimeSrc = getExternalBrowserRuntimeSrc(+ ctx,+ buildManifest,+ experimental.externalBrowserRuntime,+ assetPrefix,+ crossOrigin,+ subresourceIntegrityManifest+ )+ const [preinitScripts, bootstrapScript] = getRequiredScripts( buildManifest, // Why is assetPrefix optional on renderOpts?@@ -9525,6 +9548,7 @@ async function prerenderToStream( }, bootstrapScriptContent, bootstrapScripts: [bootstrapScript],+ unstable_externalRuntimeSrc: externalRuntimeSrc, } ) @@ -10008,6 +10032,7 @@ async function prerenderToStream( maxHeadersLength: reactMaxHeadersLength, bootstrapScriptContent, bootstrapScripts: [bootstrapScript],+ unstable_externalRuntimeSrc: externalRuntimeSrc, } ) @@ -10288,6 +10313,7 @@ async function prerenderToStream( : turbopackBootstrapScriptContent : bootstrapScriptContent, bootstrapScripts: [bootstrapScript],+ unstable_externalRuntimeSrc: externalRuntimeSrc, }, { waitForAllReady: true } )@@ -10615,6 +10641,7 @@ async function prerenderToStream( // Like the regular prerender, the Turbopack bootstrap is left // out of the shell; see `withTurbopackBootstrapScript`. bootstrapScripts: [errorBootstrapScript],+ unstable_externalRuntimeSrc: externalRuntimeSrc, formState, signal: errorClientReactController.signal, onError: (clientError: unknown, errorInfo: ErrorInfo) => {@@ -10865,6 +10892,7 @@ async function prerenderToStream( nonce, bootstrapScriptContent: errorBootstrapScriptContent, bootstrapScripts: [errorBootstrapScript],+ unstable_externalRuntimeSrc: externalRuntimeSrc, formState, }, { waitForAllReady: true }packages/next/src/server/app-render/get-external-browser-runtime-src.tsadded54 + / 0 −
@@ -0,0 +1,54 @@+import type { DeepReadonly } from '../../shared/lib/deep-readonly'+import type { BuildManifest } from '../get-page-files'+import type { AppRenderContext } from './app-render'+import { getAssetQueryString } from './get-asset-query-string'++/**+ * Resolves the `unstable_externalRuntimeSrc` value for React's Fizz options.+ *+ * When set, React streams its instruction set as `<template data-rci="" …>`+ * elements instead of inline `<script>` tags, and emits a single+ * `<script src async>` for this runtime itself. It also stops injecting its+ * inline form-replaying runtime, since the external runtime covers it.+ *+ * Returns `undefined` when the feature is off, which leaves React on its default+ * inline-script format. Only pass the result to `renderToReadableStream` and+ * `prerender`: `resume` deliberately does not accept it, because the streaming+ * format is carried in the postponed state's resumable state and the prerendered+ * shell already contains the `<script src>`.+ */+export function getExternalBrowserRuntimeSrc(+ ctx: AppRenderContext,+ buildManifest: DeepReadonly<BuildManifest>,+ externalBrowserRuntime: boolean,+ assetPrefix: string,+ crossOrigin: undefined | '' | 'anonymous' | 'use-credentials',+ subresourceIntegrityManifest: DeepReadonly<Record<string, string>> | undefined+):+ | {+ src: string+ integrity: string | undefined+ crossOrigin: string | undefined+ }+ | undefined {+ if (!externalBrowserRuntime) {+ return undefined+ }++ const file = buildManifest.externalBrowserRuntimeFile+ if (file === undefined) {+ // Deliberately fatal rather than falling back to inline scripts. Silently+ // reverting would serve a document that needs `unsafe-inline` to an app+ // configured on the assumption that it does not.+ throw new Error(+ '`experimental.externalBrowserRuntime` is enabled but the runtime asset is missing from the build manifest. ' ++ 'This usually means `.next` was produced by a build that did not have the flag enabled. Rebuild the app.'+ )+ }++ return {+ src: `${assetPrefix}/_next/${file}${getAssetQueryString(ctx, false)}`,+ integrity: subresourceIntegrityManifest?.[file],+ crossOrigin,+ }+}packages/next/src/server/app-render/types.ts6 + / 0 −
@@ -195,6 +195,12 @@ export interface RenderOptsPartial { * drives instant navigation tests. */ exposeTestingApi: boolean++ /**+ * Whether React should stream its instruction set as data, applied by the+ * standalone browser runtime asset, instead of as inline `<script>` tags.+ */+ externalBrowserRuntime: boolean } postponed?: string packages/next/src/server/base-server.ts2 + / 0 −
@@ -648,6 +648,8 @@ export default abstract class Server< (this.dev === true || this.nextConfig.experimental.exposeTestingApiInProductionBuild === true),+ externalBrowserRuntime:+ this.nextConfig.experimental.externalBrowserRuntime === true, }, prefetchHints: {}, reactMaxHeadersLength: this.nextConfig.reactMaxHeadersLength,packages/next/src/server/config-schema.ts1 + / 0 −
@@ -332,6 +332,7 @@ export const experimentalSchema = { .optional(), taint: z.boolean().optional(), blockingSSR: z.boolean().optional(),+ externalBrowserRuntime: z.boolean().optional(), prerenderEarlyExit: z.boolean().optional(), proxyTimeout: z.number().gte(0).optional(), rootParams: z.boolean().optional(),packages/next/src/server/config-shared.ts26 + / 0 −
@@ -1314,6 +1314,30 @@ export interface ExperimentalConfig { */ hydrationChangeEvent?: boolean + /**+ * Streams React's instruction set for the `app` directory as data instead of+ * as inline `<script>` tags. React emits `<template data-rci="" …>` elements,+ * and a standalone runtime script, served as a static asset from+ * `/_next/static/chunks`, collects them with a `MutationObserver` and applies+ * the instructions. This also removes React's inline form-replaying runtime.+ *+ * The goal is a document with no inline scripts, so a Content-Security-Policy+ * can drop `unsafe-inline` without a per-response nonce (which is+ * incompatible with static generation). Note that this alone is not+ * sufficient yet: the Flight payload and the bootstrap script are still+ * inlined.+ *+ * This feature is currently only available in React's experimental release+ * channel, so enabling it opts the `app` directory into `react@experimental`+ * (the same channel used by `taint`, `transitionIndicator`,+ * `gestureTransition`, and `blockingSSR`).+ *+ * This is an opt-in only. Setting it to `false` does not disable the+ * experimental channel when another feature (such as `taint`,+ * `transitionIndicator`, or `gestureTransition`) requires it.+ */+ externalBrowserRuntime?: boolean+ /** * Uninstalls all "unhandledRejection" and "uncaughtException" listeners from * the global process so that we can override the behavior, which in some@@ -2635,6 +2659,7 @@ export interface NextConfigRuntime { | 'exposeTestingApiInProductionBuild' | 'instantInsights' | 'requestInsights'+ | 'externalBrowserRuntime' > & { // Pick on @internal fields generates invalid .d.ts files /** @internal */@@ -2708,6 +2733,7 @@ export function getNextConfigRuntime( exposeTestingApiInProductionBuild: ex.exposeTestingApiInProductionBuild, instantInsights: ex.instantInsights, requestInsights: ex.requestInsights,+ externalBrowserRuntime: ex.externalBrowserRuntime, trustHostHeader: ex.trustHostHeader, isExperimentalCompile: ex.isExperimentalCompile,packages/next/src/server/get-page-files.ts4 + / 0 −
@@ -4,6 +4,10 @@ import { normalizePagePath } from '../shared/lib/page-path/normalize-page-path' export type BuildManifest = { devFiles: readonly string[] polyfillFiles: readonly string[]+ // The standalone browser runtime that applies React's instruction set when+ // `experimental.externalBrowserRuntime` is enabled. Absent otherwise. There is+ // at most one, so this is a single path rather than a list.+ externalBrowserRuntimeFile?: string lowPriorityFiles: readonly string[] rootMainFiles: readonly string[] // this is a separate field for flying shuttle to allowpackages/next/src/shared/lib/constants.ts5 + / 0 −
@@ -154,6 +154,11 @@ export const CLIENT_STATIC_FILES_RUNTIME_POLYFILLS = 'polyfills' export const CLIENT_STATIC_FILES_RUNTIME_POLYFILLS_SYMBOL = Symbol( CLIENT_STATIC_FILES_RUNTIME_POLYFILLS )+// static/chunks/external-browser-runtime.js+export const CLIENT_STATIC_FILES_RUNTIME_EXTERNAL_BROWSER_RUNTIME =+ 'external-browser-runtime'+export const CLIENT_STATIC_FILES_RUNTIME_EXTERNAL_BROWSER_RUNTIME_SYMBOL =+ Symbol(CLIENT_STATIC_FILES_RUNTIME_EXTERNAL_BROWSER_RUNTIME) export const DEFAULT_RUNTIME_WEBPACK = 'webpack-runtime' export const EDGE_RUNTIME_WEBPACK = 'edge-runtime-webpack' export const STATIC_PROPS_ID = '__N_SSG'packages/next/src/shared/lib/turbopack/manifest-loader.ts4 + / 0 −
@@ -428,6 +428,10 @@ export class TurbopackManifestLoader { if (m.rootMainFiles.length) manifest.rootMainFiles = m.rootMainFiles // polyfillFiles should always be the same, so we can overwrite instead of actually merging if (m.polyfillFiles.length) manifest.polyfillFiles = m.polyfillFiles+ // Same reasoning: every App Router page emits the identical runtime asset.+ if (m.externalBrowserRuntimeFile) {+ manifest.externalBrowserRuntimeFile = m.externalBrowserRuntimeFile+ } if (m.rootMainFilesTree) { Object.assign(manifest.rootMainFilesTree!, m.rootMainFilesTree) }packages/next/taskfile.js6 + / 1 −
@@ -1668,7 +1668,12 @@ export async function copy_vendor_react(task_) { 'cjs/react-dom-server.bun.production.min.js', 'cjs/react-dom-test-utils.development.js', 'cjs/react-dom-test-utils.production.min.js',- 'unstable_server-external-runtime.js',+ // `unstable_server-external-runtime.js` is deliberately kept. It is the+ // standalone browser runtime that applies Fizz's instruction set when+ // `unstable_externalRuntimeSrc` is set, and it is served as a static+ // asset rather than bundled. React only publishes it on the experimental+ // channel, so it is absent from the `react-dom` copy and present in the+ // `react-dom-experimental` one. ] for (const item of itemsToRemove) { yield rmrf(join(reactDomCompiledDir, item))test/cache-components-tests-manifest.json1 + / 0 −
@@ -247,6 +247,7 @@ "test/e2e/app-dir/root-layout-render-once/index.test.ts", "test/e2e/app-dir/root-layout/root-layout.test.ts", "test/e2e/app-dir/rsc-basic/rsc-basic-blocking-ssr.test.ts",+ "test/e2e/app-dir/rsc-basic/rsc-basic-external-browser-runtime.test.ts", "test/e2e/app-dir/rsc-basic/rsc-basic-react-experimental.test.ts", "test/e2e/app-dir/rsc-basic/rsc-basic.test.ts", "test/e2e/app-dir/scss/hmr-module/hmr-module.test.ts",test/e2e/app-dir/external-browser-runtime/app/counter.tsxadded14 + / 0 −
@@ -0,0 +1,14 @@+'use client'++import { useState } from 'react'++// Exists purely to prove hydration ran: the count only changes if the client+// bundle attached its event handlers.+export function Counter() {+ const [count, setCount] = useState(0)+ return (+ <button id="counter" onClick={() => setCount((c) => c + 1)}>+ count: {count}+ </button>+ )+}test/e2e/app-dir/external-browser-runtime/app/layout.tsxadded8 + / 0 −
@@ -0,0 +1,8 @@+import { ReactNode } from 'react'+export default function Root({ children }: { children: ReactNode }) {+ return (+ <html>+ <body>{children}</body>+ </html>+ )+}test/e2e/app-dir/external-browser-runtime/app/page.tsxadded27 + / 0 −
@@ -0,0 +1,27 @@+import { Suspense } from 'react'+import { connection } from 'next/server'+import { Counter } from './counter'++async function Slow() {+ // Dynamic access lives inside the boundary so the shell can still be+ // prerendered. With Cache Components that makes this a PPR route: the shell is+ // prerendered and this subtree is resumed per request, which exercises React's+ // `resume` path. Without Cache Components the whole route is dynamic. Either+ // way the shell flushes before this resolves, so React has to stream a+ // "complete boundary" instruction, which is what the external runtime applies.+ await connection()+ await new Promise((resolve) => setTimeout(resolve, 500))+ return <p id="slow">slow content</p>+}++export default function Page() {+ return (+ <>+ <p id="shell">hello world</p>+ <Suspense fallback={<p id="fallback">loading...</p>}>+ <Slow />+ </Suspense>+ <Counter />+ </>+ )+}test/e2e/app-dir/external-browser-runtime/external-browser-runtime.test.tsadded71 + / 0 −
@@ -0,0 +1,71 @@+import { nextTestSetup } from 'e2e-utils'+import { retry } from 'next-test-utils'++describe('external-browser-runtime', () => {+ const { next } = nextTestSetup({+ files: __dirname,+ })++ it('streams React instructions as data instead of inline scripts', async () => {+ const html = await next.render('/')++ // `data-rci` is the "complete boundary" instruction React emits once the+ // Suspense boundary resolves. Its presence is the direct evidence that Fizz+ // switched from its SCRIPT streaming format to the DATA format.+ //+ // Asserting on a React-internal attribute is more coupling than we would+ // like. It is the only signal available today. Once the Flight payload and+ // `bootstrapScriptContent` also stop being inlined, replace this with the+ // assertion that actually matters: that the document contains no inline+ // `<script>` at all.+ expect(html).toContain('<template data-rci=""')+ })++ it('loads the runtime as a static asset', async () => {+ const $ = await next.render$('/')++ // React emits the runtime as an async script in the head. The filename is+ // not asserted on: webpack names it readably while Turbopack uses an opaque+ // content hash, so the asset is identified by what it contains instead.+ const srcs = $('head script[src]')+ .map((_, el) => $(el).attr('src'))+ .get()+ expect(srcs.length).toBeGreaterThan(0)++ const responses = await Promise.all(+ srcs.map(async (src) => {+ const res = await next.fetch(new URL(src, 'http://n').pathname)+ return { src, status: res.status, body: await res.text() }+ })+ )++ // `installFizzInstrObserver` is the runtime's entry point: it attaches the+ // MutationObserver that picks up React's `<template data-r*i>` elements.+ const runtimes = responses.filter((r) =>+ r.body.includes('installFizzInstrObserver')+ )+ expect(runtimes).toHaveLength(1)+ expect(runtimes[0].status).toBe(200)+ expect($(`script[src="${runtimes[0].src}"]`).attr('crossorigin')).toBe(+ 'use-credentials'+ )+ })++ it('reveals suspended content and hydrates', async () => {+ const browser = await next.browser('/')++ // The boundary content only appears if the external runtime picked up the+ // `data-rci` template and ran the instruction.+ await retry(async () => {+ expect(await browser.elementByCss('#slow').text()).toBe('slow content')+ })++ expect(await browser.elementByCss('#shell').text()).toBe('hello world')++ // Proves hydration attached handlers.+ await browser.elementByCss('#counter').click()+ await retry(async () => {+ expect(await browser.elementByCss('#counter').text()).toBe('count: 1')+ })+ })+})test/e2e/app-dir/external-browser-runtime/next.config.jsadded11 + / 0 −
@@ -0,0 +1,11 @@+/**+ * @type {import('next').NextConfig}+ */+const nextConfig = {+ crossOrigin: 'use-credentials',+ experimental: {+ externalBrowserRuntime: true,+ },+}++module.exports = nextConfigtest/e2e/app-dir/rsc-basic/rsc-basic-external-browser-runtime.test.tsadded81 + / 0 −
@@ -0,0 +1,81 @@+import { nextTestSetup } from 'e2e-utils'++describe('react@experimental - externalBrowserRuntime', () => {+ const { next } = nextTestSetup({+ files: __dirname,+ overrideFiles: {+ 'next.config.js': `+ module.exports = {+ experimental: {+ externalBrowserRuntime: true,+ }+ }+ `,+ },+ })++ // React gates the Fizz external runtime behind `enableFizzExternalRuntime`,+ // which is only enabled in its experimental build. On the canary channel+ // `createRenderState` hardcodes `externalRuntimeScript: null`, so passing+ // `unstable_externalRuntimeSrc` would be a silent no-op. The flag therefore has+ // to opt into `react@experimental`, and this asserts that it does.+ it('should opt into the react@experimental channel when externalBrowserRuntime is enabled', async () => {+ const resPages$ = await next.render$('/app-react')+ const [+ ssrReact,+ ssrReactDOM,+ ssrClientReact,+ ssrClientReactDOM,+ ssrClientReactDOMServer,+ ] = [+ resPages$('#react').text(),+ resPages$('#react-dom').text(),+ resPages$('#client-react').text(),+ resPages$('#client-react-dom').text(),+ resPages$('#client-react-dom-server').text(),+ ]+ expect({+ ssrReact,+ ssrReactDOM,+ ssrClientReact,+ ssrClientReactDOM,+ ssrClientReactDOMServer,+ }).toEqual({+ ssrReact: expect.stringMatching('-experimental-'),+ ssrReactDOM: expect.stringMatching('-experimental-'),+ ssrClientReact: expect.stringMatching('-experimental-'),+ ssrClientReactDOM: expect.stringMatching('-experimental-'),+ ssrClientReactDOMServer: expect.stringMatching('-experimental-'),+ })++ const browser = await next.browser('/app-react')+ const [+ browserReact,+ browserReactDOM,+ browserClientReact,+ browserClientReactDOM,+ browserClientReactDOMServer,+ ] = await browser.eval(`+ [+ document.querySelector('#react').innerText,+ document.querySelector('#react-dom').innerText,+ document.querySelector('#client-react').innerText,+ document.querySelector('#client-react-dom').innerText,+ document.querySelector('#client-react-dom-server').innerText,+ ]+ `)+ expect({+ browserReact,+ browserReactDOM,+ browserClientReact,+ browserClientReactDOM,+ browserClientReactDOMServer,+ }).toEqual({+ browserReact: expect.stringMatching('-experimental-'),+ browserReactDOM: expect.stringMatching('-experimental-'),+ browserClientReact: expect.stringMatching('-experimental-'),+ browserClientReactDOM: expect.stringMatching('-experimental-'),+ browserClientReactDOMServer: expect.stringMatching('-experimental-'),+ })+ })+})