vercel/next.js · #97570

[no-unsafe-inline] Add an experimental flag for React's external browser runtime

eps1lon · merged Nov 2, 202629 files · 975 + / 6 −
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: self
packages/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 allow
packages/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 = nextConfig
test/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-'),+    })+  })+})