Files
next.js/scripts/sync-react.js
T

360 lines
11 KiB
JavaScript

// @ts-check
const path = require('path')
const fsp = require('fs/promises')
const process = require('process')
const execa = require('execa')
const yargs = require('yargs')
/** @type {any} */
const fetch = require('node-fetch')
const filesReferencingReactPeerDependencyVersion = [
'run-tests.js',
'packages/create-next-app/templates/index.ts',
'test/lib/next-modes/base.ts',
]
const appManifestsInstallingNextjsPeerDependencies = [
'examples/reproduction-template/package.json',
'test/.stats-app/package.json',
// TODO: These should use the usual test helpers that automatically install the right React version
'test/e2e/next-test/first-time-setup-js/package.json',
'test/e2e/next-test/first-time-setup-ts/package.json',
]
// 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
async function sync({
channel,
newVersionStr,
newSha,
newDateString,
noInstall,
}) {
const useExperimental = channel === 'experimental'
const cwd = process.cwd()
const pkgJson = JSON.parse(
await fsp.readFile(path.join(cwd, 'package.json'), 'utf-8')
)
const devDependencies = pkgJson.devDependencies
const pnpmOverrides = pkgJson.pnpm.overrides
const baseVersionStr = devDependencies[
useExperimental ? 'react-experimental-builtin' : 'react-builtin'
].replace(/^npm:react@/, '')
const baseVersionInfo = extractInfoFromReactVersion(baseVersionStr)
if (!baseVersionInfo) {
throw new Error(
'Base react version does not match expected format: ' + baseVersionStr
)
}
const {
sha: baseSha,
releaseLabel: baseReleaseLabel,
dateString: baseDateString,
} = baseVersionInfo
console.log(`Updating "react@${channel}" to ${newSha}...\n`)
if (newSha === baseSha) {
console.log('Already up to date.')
return
}
for (const [dep, version] of Object.entries(devDependencies)) {
if (version.endsWith(`${baseReleaseLabel}-${baseSha}-${baseDateString}`)) {
devDependencies[dep] = version.replace(
`${baseReleaseLabel}-${baseSha}-${baseDateString}`,
`${channel}-${newSha}-${newDateString}`
)
}
}
for (const [dep, version] of Object.entries(pnpmOverrides)) {
if (version.endsWith(`${baseReleaseLabel}-${baseSha}-${baseDateString}`)) {
pnpmOverrides[dep] = version.replace(
`${baseReleaseLabel}-${baseSha}-${baseDateString}`,
`${channel}-${newSha}-${newDateString}`
)
}
}
await fsp.writeFile(
path.join(cwd, 'package.json'),
JSON.stringify(pkgJson, null, 2) +
// Prettier would add a newline anyway so do it manually to skip the additional `pnpm prettier-write`
'\n'
)
}
function extractInfoFromReactVersion(reactVersion) {
const match = reactVersion.match(
/(?<semverVersion>.*)-(?<releaseLabel>.*)-(?<sha>.*)-(?<dateString>.*)$/
)
return match ? match.groups : null
}
async function getChangelogFromGitHub(baseSha, newSha) {
const pageSize = 50
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 = {}
// 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}`
}
const response = await fetch(url, {
headers,
})
if (!response.ok) {
throw new Error(
`${response.url}: Failed to fetch commit log from GitHub:\n${response.statusText}\n${await response.text()}`
)
}
const data = await response.json()
const { commits } = data
for (const { commit, sha } of commits) {
const title = commit.message.split('\n')[0] || ''
const match =
// The "title" looks like "[Fiber][Float] preinitialized stylesheets should support integrity option (#26881)"
/\(#([0-9]+)\)$/.exec(title) ??
// or contains "Pull Request resolved: https://github.com/facebook/react/pull/12345" in the body if merged via ghstack (e.g. https://github.com/facebook/react/commit/0a0a5c02f138b37e93d5d93341b494d0f5d52373)
/^Pull Request resolved: https:\/\/github.com\/facebook\/react\/pull\/([0-9]+)$/m.exec(
commit.message
)
const prNum = match ? match[1] : ''
if (prNum) {
changelog.push(`- https://github.com/facebook/react/pull/${prNum}`)
} else {
changelog.push(
`- [${commit.message.split('\n')[0]} facebook/react@${sha.slice(0, 9)}](https://github.com/facebook/react/commit/${sha}) (${commit.author.name})`
)
}
}
if (commits.length < pageSize) {
// If the number of commits is less than the page size, we've reached
// the end. Otherwise we'll keep fetching until we run out.
break
}
}
changelog.reverse()
return changelog.length > 0 ? changelog.join('\n') : null
}
async function main() {
const cwd = process.cwd()
const errors = []
const { noInstall, version } = await yargs(process.argv.slice(2))
.options('version', { default: null, type: 'string' })
.options('no-install', { default: false, type: 'boolean' }).argv
let newVersionStr = version
if (newVersionStr === null) {
const { stdout, stderr } = await execa(
'npm',
['view', 'react@canary', 'version'],
{
// Avoid "Usage Error: This project is configured to use pnpm".
cwd: '/tmp',
}
)
if (stderr) {
console.error(stderr)
throw new Error('Failed to read latest React canary version from npm.')
}
newVersionStr = stdout.trim()
}
const newVersionInfo = extractInfoFromReactVersion(newVersionStr)
if (!newVersionInfo) {
throw new Error(
`New react version does not match expected format: ${newVersionStr}
Choose a React canary version from npm: https://www.npmjs.com/package/react?activeTab=versions
Or, run this command with no arguments to use the most recently published version.
`
)
}
const { sha: newSha, dateString: newDateString } = newVersionInfo
const rootManifest = JSON.parse(
await fsp.readFile(path.join(cwd, 'package.json'), 'utf-8')
)
const baseVersionStr = rootManifest.devDependencies['react-builtin'].replace(
/^npm:react@/,
''
)
await sync({
newDateString,
newSha,
newVersionStr,
noInstall,
channel: 'experimental',
})
await sync({
newDateString,
newSha,
newVersionStr,
noInstall,
channel: 'rc',
})
const baseVersionInfo = extractInfoFromReactVersion(baseVersionStr)
if (!baseVersionInfo) {
throw new Error(
'Base react version does not match expected format: ' + baseVersionStr
)
}
const { sha: baseSha, dateString: baseDateString } = baseVersionInfo
for (const fileName of filesReferencingReactPeerDependencyVersion) {
const filePath = path.join(cwd, fileName)
const previousSource = await fsp.readFile(filePath, 'utf-8')
const updatedSource = previousSource.replace(
`const nextjsReactPeerVersion = "${baseVersionStr}";`,
`const nextjsReactPeerVersion = "${newVersionStr}";`
)
if (updatedSource === previousSource) {
errors.push(
new Error(
`${fileName}: Failed to update ${baseVersionStr} to ${newVersionStr}. Is this file still referencing the React peer dependency version?`
)
)
} else {
await fsp.writeFile(filePath, updatedSource)
}
}
const nextjsPackageJsonPath = path.join(
process.cwd(),
'packages',
'next',
'package.json'
)
const nextjsPackageJson = JSON.parse(
await fsp.readFile(nextjsPackageJsonPath, 'utf-8')
)
nextjsPackageJson.peerDependencies.react = `${newVersionStr}`
nextjsPackageJson.peerDependencies['react-dom'] = `${newVersionStr}`
await fsp.writeFile(
nextjsPackageJsonPath,
JSON.stringify(nextjsPackageJson, null, 2) +
// Prettier would add a newline anyway so do it manually to skip the additional `pnpm prettier-write`
'\n'
)
for (const fileName of appManifestsInstallingNextjsPeerDependencies) {
const packageJsonPath = path.join(cwd, fileName)
const packageJson = await fsp.readFile(packageJsonPath, 'utf-8')
const manifest = JSON.parse(packageJson)
manifest.dependencies['react'] = newVersionStr
manifest.dependencies['react-dom'] = newVersionStr
await fsp.writeFile(
packageJsonPath,
JSON.stringify(manifest, null, 2) +
// Prettier would add a newline anyway so do it manually to skip the additional `pnpm prettier-write`
'\n'
)
}
// Install the updated dependencies and build the vendored React files.
if (noInstall) {
console.log('Skipping install step because --no-install flag was passed.\n')
} else {
console.log('Installing dependencies...\n')
const installSubprocess = execa('pnpm', ['install'])
if (installSubprocess.stdout) {
installSubprocess.stdout.pipe(process.stdout)
}
try {
await installSubprocess
} catch (error) {
console.error(error)
throw new Error('Failed to install updated dependencies.')
}
console.log('Building vendored React files...\n')
const nccSubprocess = execa('pnpm', ['ncc-compiled'], {
cwd: path.join(cwd, 'packages', 'next'),
})
if (nccSubprocess.stdout) {
nccSubprocess.stdout.pipe(process.stdout)
}
try {
await nccSubprocess
} catch (error) {
console.error(error)
throw new Error('Failed to run ncc.')
}
// Print extra newline after ncc output
console.log()
}
console.log(
`**breaking change for canary users: Bumps peer dependency of React from \`${baseVersionStr}\` to \`${newVersionStr}\`**`
)
// Fetch the changelog from GitHub and print it to the console.
console.log(
`[diff facebook/react@${baseSha}...${newSha}](https://github.com/facebook/react/compare/${baseSha}...${newSha})`
)
try {
const changelog = await getChangelogFromGitHub(baseSha, newSha)
if (changelog === null) {
console.log(
`GitHub reported no changes between ${baseSha} and ${newSha}.`
)
} else {
console.log(
`<details>\n<summary>React upstream changes</summary>\n\n${changelog}\n\n</details>`
)
}
} catch (error) {
console.error(error)
console.log(
'\nFailed to fetch changelog from GitHub. Changes were applied, anyway.\n'
)
}
if (noInstall) {
console.log(
`
To finish upgrading, complete the following steps:
- Install the updated dependencies: pnpm install
- Build the vendored React files: (inside packages/next dir) pnpm ncc-compiled
Or run this command again without the --no-install flag to do both automatically.
`
)
}
await fsp.writeFile(path.join(cwd, '.github/.react-version'), newVersionStr)
if (errors.length) {
// eslint-disable-next-line no-undef -- Defined in Node.js
throw new AggregateError(errors)
}
console.log(
`Successfully updated React from \`${baseSha}-${baseDateString}\` to \`${newSha}-${newDateString}\``
)
}
main().catch((error) => {
console.error(error)
process.exit(1)
})