diff --git a/scripts/sync-react.js b/scripts/sync-react.js index 4119aff3fabe06..cf80b08d9a8a52 100644 --- a/scripts/sync-react.js +++ b/scripts/sync-react.js @@ -2,7 +2,9 @@ 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') @@ -28,45 +30,14 @@ const appManifestsInstallingNextjsPeerDependencies = [ // Update package.json but skip installing the dependencies automatically: // pnpm run sync-react --no-install -async function sync({ channel, useAsPeerDependency }) { - const errors = [] - - const noInstall = readBoolArg(process.argv, 'no-install') +async function sync({ + channel, + newVersionStr, + newSha, + newDateString, + noInstall, +}) { const useExperimental = channel === 'experimental' - let newVersionStr = readStringArg(process.argv, 'version') - if (newVersionStr === null) { - const { stdout, stderr } = await execa( - 'npm', - [ - 'view', - useExperimental ? 'react@experimental' : 'react@next', - '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. -` - ) - } - newVersionInfo.releaseLabel = channel - const cwd = process.cwd() const pkgJson = JSON.parse( await fsp.readFile(path.join(cwd, 'package.json'), 'utf-8') @@ -84,11 +55,6 @@ Or, run this command with no arguments to use the most recently published versio ) } - const { - sha: newSha, - releaseLabel: newReleaseLabel, - dateString: newDateString, - } = newVersionInfo const { sha: baseSha, releaseLabel: baseReleaseLabel, @@ -105,7 +71,7 @@ Or, run this command with no arguments to use the most recently published versio if (version.endsWith(`${baseReleaseLabel}-${baseSha}-${baseDateString}`)) { devDependencies[dep] = version.replace( `${baseReleaseLabel}-${baseSha}-${baseDateString}`, - `${newReleaseLabel}-${newSha}-${newDateString}` + `${channel}-${newSha}-${newDateString}` ) } } @@ -113,7 +79,7 @@ Or, run this command with no arguments to use the most recently published versio if (version.endsWith(`${baseReleaseLabel}-${baseSha}-${baseDateString}`)) { pnpmOverrides[dep] = version.replace( `${baseReleaseLabel}-${baseSha}-${baseDateString}`, - `${newReleaseLabel}-${newSha}-${newDateString}` + `${channel}-${newSha}-${newDateString}` ) } } @@ -123,60 +89,184 @@ Or, run this command with no arguments to use the most recently published versio // Prettier would add a newline anyway so do it manually to skip the additional `pnpm prettier-write` '\n' ) - console.log('Successfully updated React dependencies in package.json.\n') - - if (useAsPeerDependency) { - 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}";` +} + +function extractInfoFromReactVersion(reactVersion) { + const match = reactVersion.match( + /(?.*)-(?.*)-(?.*)-(?.*)$/ + ) + 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()}` ) - if (updatedSource === previousSource) { - errors.push( - new Error( - `${fileName}: Failed to update ${baseVersionStr} to ${newVersionStr}. Is this file still referencing the React peer dependency version?` - ) + } + 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 { - await fsp.writeFile(filePath, updatedSource) + changelog.push( + `- [${commit.message.split('\n')[0]} facebook/react@${sha.slice(0, 9)}](https://github.com/facebook/react/commit/${sha}) (${commit.author.name})` + ) } } - const nextjsPackageJsonPath = path.join( - cwd, - 'packages', - 'next', - 'package.json' + 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', + } ) - const nextjsPackageJson = JSON.parse( - await fsp.readFile(nextjsPackageJsonPath, 'utf-8') + 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. +` ) - 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' + } + 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 ) + } - 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' + 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') @@ -212,11 +302,9 @@ Or, run this command with no arguments to use the most recently published versio console.log() } - if (useAsPeerDependency) { - console.log( - `**breaking change for canary users: Bumps peer dependency of React from \`${baseVersionStr}\` to \`${newVersionStr}\`**` - ) - } + 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( @@ -265,77 +353,7 @@ Or run this command again without the --no-install flag to do both automatically ) } -function readBoolArg(argv, argName) { - return argv.indexOf('--' + argName) !== -1 -} - -function readStringArg(argv, argName) { - const argIndex = argv.indexOf('--' + argName) - return argIndex === -1 ? null : argv[argIndex + 1] -} - -function extractInfoFromReactVersion(reactVersion) { - const match = reactVersion.match( - /(?.*)-(?.*)-(?.*)-(?.*)$/ - ) - 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 -} - -sync({ channel: 'experimental', useAsPeerDependency: false }) - .then(() => sync({ channel: 'rc', useAsPeerDependency: true })) - .catch((error) => { - console.error(error) - process.exit(1) - }) +main().catch((error) => { + console.error(error) + process.exit(1) +})