Repository: vercel/next.js
Pull request: [sync-react] Allow syncing React with local build

Adds support for syncing react with a local build which is not meant to be submitted as PR e.g. `pnpm run sync-react --version /path/to/react/checkout/`.

This only works when all necessary packages and entrypoints are built fully. 

<details>
<summary>bash command to build all Next.js relevant packages, channels, and bundles</summary>

```bash

function buildForNext() {
  set -x
  bundles=(
    "react/index"
    "react/jsx"
    "react-jsx-runtime.react-server"
    "react-jsx-dev-runtime.react-server"
    "react/compiler-runtime"
    "react.react-server"
    "react-dom/index"
    "react-dom/client"
    "react-dom/profiling"
    "react-dom/server"
    "react-dom.react-server"
    "react-dom-server.browser"
    "react-dom-server.bun"
    "react-dom-server.edge"
    "react-dom-server.node"
    "react-dom-server-legacy.browser"
    "react-dom-server-legacy.node"
    "react-is"
    "scheduler"
    "react-server-dom-webpack/"
    "react-server-dom-turbopack/"
  )
  type="BUN_DEV,BUN_PROD,NODE_DEV,NODE_PROD,NODE_PROFILING,ESM_DEV,ESM_PROD,NODE_ES2015"
  RELEASE_CHANNEL=stable node ./scripts/rollup/build.js \
    ${bundles[@]} \
    --type="$type"
  mv ./build/node_modules ./build/oss-stable
  RELEASE_CHANNEL=experimental node ./scripts/rollup/build.js \
    ${bundles[@]} \
    --type="$type" \
    --unsafe-partial
  mv ./build/node_modules ./build/oss-experimental
}
```
</details> 

Next step is adding support for URLs

## Test plan

- [x] `p sync-react --version /Users/sebbie/repos/react`
- [x] `p sync-react --version 19.3.0-canary-4842fbea-20260217`

Changed files:
filename: scripts/sync-react.js
patch: @@ -3,13 +3,22 @@
 const path = require('path')
 const fsp = require('fs/promises')
 const process = require('process')
+const { pathToFileURL } = require('url')
 const execa = require('execa')
 const { Octokit } = require('octokit')
 const SemVer = require('semver')
 const yargs = require('yargs')
 
-/** @type {any} */
-const fetch = require('node-fetch')
+// Use this script to update Next's vendored copy of React and related packages:
+//
+// Basic usage (defaults to most recent React canary version):
+//   pnpm run sync-react
+//
+// Update package.json but skip installing the dependencies automatically:
+//   pnpm run sync-react --no-install
+//
+// Sync from a local checkout of React (requires having React built first):
+//   pnpm run sync-react --version /path/to/react/checkout/
 
 const repoOwner = 'vercel'
 const repoName = 'next.js'
@@ -45,6 +54,10 @@ const appManifestsInstallingNextjsPeerDependencies = [
 ]
 
 async function getSchedulerVersion(reactVersion) {
+  if (reactVersion.startsWith('file://')) {
+    return reactVersion
+  }
+
   const url = `https://registry.npmjs.org/react-dom/${reactVersion}`
   const response = await fetch(url, {
     headers: {
@@ -62,13 +75,18 @@ async function getSchedulerVersion(reactVersion) {
   return manifest.dependencies['scheduler']
 }
 
-// Use this script to update Next's vendored copy of React and related packages:
-//
-// Basic usage (defaults to most recent React canary version):
-//   pnpm run sync-react
-//
-// Update package.json but skip installing the dependencies automatically:
-//   pnpm run sync-react --no-install
+/**
+ * @param {string} packageName
+ * @param {string} versionStr An NPM version or a file URL to a React checkout
+ * @returns {string}
+ */
+function getPackageVersion(packageName, versionStr) {
+  if (versionStr.startsWith('file://')) {
+    return new URL(packageName, versionStr).href
+  }
+
+  return `npm:${packageName}@${versionStr}`
+}
 
 async function sync({ channel, newVersionStr, noInstall }) {
   const useExperimental = channel === 'experimental'
@@ -88,32 +106,45 @@ async function sync({ channel, newVersionStr, noInstall }) {
     return
   }
 
-  const baseSchedulerVersionStr = devDependencies[
-    useExperimental ? 'scheduler-experimental-builtin' : 'scheduler-builtin'
-  ].replace(/^npm:scheduler@/, '')
   const newSchedulerVersionStr = await getSchedulerVersion(newVersionStr)
   console.log(`Updating "scheduler@${channel}" to ${newSchedulerVersionStr}...`)
 
-  for (const [dep, version] of Object.entries(devDependencies)) {
-    if (version.endsWith(baseVersionStr)) {
-      devDependencies[dep] = version.replace(baseVersionStr, newVersionStr)
-    } else if (version.endsWith(baseSchedulerVersionStr)) {
-      devDependencies[dep] = version.replace(
-        baseSchedulerVersionStr,
-        newSchedulerVersionStr
-      )
+  for (const packageName of ['react', 'react-dom']) {
+    devDependencies[
+      `${packageName}${useExperimental ? '-experimental' : ''}-builtin`
+    ] = getPackageVersion(packageName, newVersionStr)
+
+    if (!useExperimental) {
+      pnpmOverrides[packageName] = getPackageVersion(packageName, newVersionStr)
     }
   }
-  for (const [dep, version] of Object.entries(pnpmOverrides)) {
-    if (version.endsWith(baseVersionStr)) {
-      pnpmOverrides[dep] = version.replace(baseVersionStr, newVersionStr)
-    } else if (version.endsWith(baseSchedulerVersionStr)) {
-      pnpmOverrides[dep] = version.replace(
-        baseSchedulerVersionStr,
-        newSchedulerVersionStr
-      )
-    }
+
+  for (const packageName of [
+    'react-server-dom-turbopack',
+    'react-server-dom-webpack',
+  ]) {
+    devDependencies[`${packageName}${useExperimental ? '-experimental' : ''}`] =
+      getPackageVersion(packageName, newVersionStr)
   }
+
+  devDependencies[
+    `scheduler-${useExperimental ? 'experimental-' : ''}builtin`
+  ] = getPackageVersion('scheduler', newSchedulerVersionStr)
+  if (!useExperimental) {
+    pnpmOverrides.scheduler = getPackageVersion(
+      'scheduler',
+      newSchedulerVersionStr
+    )
+
+    // TODO: Should be handled like the other React packages
+    devDependencies['react-is-builtin'] = newVersionStr.startsWith('file://')
+      ? new URL('react-is', newVersionStr).href
+      : `npm:react-is@${newVersionStr}`
+    pnpmOverrides['react-is'] = newVersionStr.startsWith('file://')
+      ? new URL('react-is', newVersionStr).href
+      : `npm:react-is@${newVersionStr}`
+  }
+
   await fsp.writeFile(
     path.join(cwd, 'package.json'),
     JSON.stringify(pkgJson, null, 2) +
@@ -122,8 +153,25 @@ async function sync({ channel, newVersionStr, noInstall }) {
   )
 }
 
-function extractInfoFromReactVersion(reactVersion) {
-  const match = reactVersion.match(
+/**
+ * @typedef {object} ReactVersionInfo
+ * @property {string} semverVersion - The semver version of React.
+ * @property {string} releaseLabel - The release label of React (e.g. "canary", "rc").
+ * @property {string} sha - The commit SHA of the React version.
+ * @property {string} dateString - The date string of the React version.
+ * @returns {ReactVersionInfo}
+ */
+function extractInfoFromReactVersion(versionStr) {
+  if (versionStr.startsWith('file://')) {
+    return {
+      dateString: new Date().toISOString().split('T')[0],
+      releaseLabel: 'local',
+      semverVersion: '0.0.0',
+      sha: 'local',
+    }
+  }
+
+  const match = versionStr.match(
     /(?<semverVersion>.*)-(?<releaseLabel>.*)-(?<sha>.*)-(?<dateString>.*)$/
   )
   return match ? match.groups : null
@@ -134,10 +182,10 @@ async function getChangelogFromGitHub(baseSha, newSha) {
   let changelog = []
   for (let currentPage = 1; ; currentPage++) {
     const url = `https://api.github.com/repos/facebook/react/compare/${baseSha}...${newSha}?per_page=${pageSize}&page=${currentPage}`
-    const headers = {}
+    const headers = new Headers()
     // GITHUB_TOKEN is optional but helps in case of rate limiting during development.
     if (process.env.GITHUB_TOKEN) {
-      headers.Authorization = `token ${process.env.GITHUB_TOKEN}`
+      headers.set('Authorization', `token ${process.env.GITHUB_TOKEN}`)
     }
     const response = await fetch(url, {
       headers,
@@ -229,7 +277,14 @@ async function main() {
     })
     .options('install', { default: true, type: 'boolean' })
     .options('version', { default: null, type: 'string' }).argv
-  const { actor, createPull, commit, install, version } = argv
+  let { actor, createPull, commit, install, version } = argv
+  if (version !== null && version.startsWith('/')) {
+    version = pathToFileURL(version).href
+    // Ensure trailing slash so that the URL is treated as a directory.
+    if (!version.endsWith('/')) {
+      version += '/'
+    }
+  }
 
   async function commitEverything(message) {
     await execa('git', ['add', '-A'])
@@ -281,7 +336,10 @@ Or, run this command with no arguments to use the most recently published versio
   }
   const { sha: newSha, dateString: newDateString } = newVersionInfo
 
-  const branchName = `update/react/${newVersionStr}`
+  const branchName = newVersionStr.startsWith('file://')
+    ? // left to user to name their local sync branch
+      `update/react/local`
+    : `update/react/${newVersionStr}`
   if (createPull) {
     const { exitCode, all, command } = await execa(
       'git',
@@ -319,8 +377,14 @@ Or, run this command with no arguments to use the most recently published versio
     ''
   )
 
+  let experimentalNewVersionStr = `0.0.0-experimental-${newSha}-${newDateString}`
+  if (version !== null && version.startsWith('file://')) {
+    experimentalNewVersionStr = new URL('build/oss-experimental/', version).href
+    newVersionStr = new URL('build/oss-stable/', version).href
+  }
+
   await sync({
-    newVersionStr: `0.0.0-experimental-${newSha}-${newDateString}`,
+    newVersionStr: experimentalNewVersionStr,
     noInstall: !install,
     channel: 'experimental',
   })
@@ -470,23 +534,27 @@ Or, run this command with no arguments to use the most recently published versio
   }
 
   let prDescription = ''
-  if (syncPagesRouterReact) {
-    prDescription += `**breaking change for canary users: Bumps peer dependency of React from \`${baseVersionStr}\` to \`${pagesRouterReactVersion}\`**\n\n`
-  }
+  if (newVersionInfo.releaseLabel === 'local') {
+    prDescription = "Can't generate a changelog for local builds"
+  } else {
+    if (syncPagesRouterReact) {
+      prDescription += `**breaking change for canary users: Bumps peer dependency of React from \`${baseVersionStr}\` to \`${pagesRouterReactVersion}\`**\n\n`
+    }
 
-  // Fetch the changelog from GitHub and print it to the console.
-  prDescription += `[diff facebook/react@${baseSha}...${newSha}](https://github.com/facebook/react/compare/${baseSha}...${newSha})\n\n`
-  try {
-    const changelog = await getChangelogFromGitHub(baseSha, newSha)
-    if (changelog === null) {
-      prDescription += `GitHub reported no changes between ${baseSha} and ${newSha}.`
-    } else {
-      prDescription += `<details>\n<summary>React upstream changes</summary>\n\n${changelog}\n\n</details>`
+    // Fetch the changelog from GitHub and print it to the console.
+    prDescription += `[diff facebook/react@${baseSha}...${newSha}](https://github.com/facebook/react/compare/${baseSha}...${newSha})\n\n`
+    try {
+      const changelog = await getChangelogFromGitHub(baseSha, newSha)
+      if (changelog === null) {
+        prDescription += `GitHub reported no changes between ${baseSha} and ${newSha}.`
+      } else {
+        prDescription += `<details>\n<summary>React upstream changes</summary>\n\n${changelog}\n\n</details>`
+      }
+    } catch (error) {
+      console.error(error)
+      prDescription +=
+        '\nFailed to fetch changelog from GitHub. Changes were applied, anyway.\n'
     }
-  } catch (error) {
-    console.error(error)
-    prDescription +=
-      '\nFailed to fetch changelog from GitHub. Changes were applied, anyway.\n'
   }
 
   if (!install) {