diff --git a/.github/workflows/runtime_commit_artifacts.yml b/.github/workflows/runtime_commit_artifacts.yml index b982d561ed71c..1b98673cd4dd6 100644 --- a/.github/workflows/runtime_commit_artifacts.yml +++ b/.github/workflows/runtime_commit_artifacts.yml @@ -162,10 +162,13 @@ jobs: mv build/facebook-react-native/react-is/cjs/ $BASE_FOLDER/RKJSModules/vendor/react/react-is/ mv build/facebook-react-native/react-test-renderer/cjs/ $BASE_FOLDER/RKJSModules/vendor/react/react-test-renderer/ - # Delete OSS renderer. OSS renderer is synced through internal script. + # Delete the OSS renderers, these are sync'd to RN separately. RENDERER_FOLDER=$BASE_FOLDER/react-native-github/Libraries/Renderer/implementations/ rm $RENDERER_FOLDER/ReactFabric-{dev,prod,profiling}.js - rm $RENDERER_FOLDER/ReactNativeRenderer-{dev,prod,profiling}.js + + # Delete the legacy renderer shim, this is not sync'd and will get deleted in the future. + SHIM_FOLDER=$BASE_FOLDER/react-native-github/Libraries/Renderer/shims/ + rm $SHIM_FOLDER/ReactNative.js # Copy eslint-plugin-react-hooks # NOTE: This is different from www, here we include the full package diff --git a/package.json b/package.json index 7bb0c2c5c9350..1b88aa171a811 100644 --- a/package.json +++ b/package.json @@ -152,6 +152,7 @@ "download-build-in-codesandbox-ci": "yarn build --type=node react/index react.react-server react-dom/index react-dom/client react-dom/src/server react-dom/test-utils react-dom.react-server scheduler/index react/jsx-runtime react/jsx-dev-runtime react-server-dom-webpack", "check-release-dependencies": "node ./scripts/release/check-release-dependencies", "generate-inline-fizz-runtime": "node ./scripts/rollup/generate-inline-fizz-runtime.js", + "generate-changelog": "node ./scripts/tasks/generate-changelog/index.js", "flags": "node ./scripts/flags/flags.js" }, "resolutions": { diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index c7506a13e5941..503f79e4cc582 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -624,6 +624,22 @@ function wakeChunkIfInitialized( rejectListeners.splice(rejectionIdx, 1); } } + // The status might have changed after fulfilling the reference. + switch ((chunk: SomeChunk).status) { + case INITIALIZED: + const initializedChunk: InitializedChunk = (chunk: any); + wakeChunk( + resolveListeners, + initializedChunk.value, + initializedChunk, + ); + return; + case ERRORED: + if (rejectListeners !== null) { + rejectChunk(rejectListeners, chunk.reason); + } + return; + } } } } diff --git a/packages/react-dom/npm/server.bun.js b/packages/react-dom/npm/server.bun.js index bb44b38ec3c77..ec94e0f0bc4b5 100644 --- a/packages/react-dom/npm/server.bun.js +++ b/packages/react-dom/npm/server.bun.js @@ -12,6 +12,8 @@ if (process.env.NODE_ENV === 'production') { exports.version = b.version; exports.renderToReadableStream = b.renderToReadableStream; +exports.renderToPipeableStream = b.renderToPipeableStream; +exports.resumeToPipeableStream = b.resumeToPipeableStream; exports.resume = b.resume; exports.renderToString = l.renderToString; exports.renderToStaticMarkup = l.renderToStaticMarkup; diff --git a/packages/react-dom/server.bun.js b/packages/react-dom/server.bun.js index 7d054e5534e2b..13e312e559a97 100644 --- a/packages/react-dom/server.bun.js +++ b/packages/react-dom/server.bun.js @@ -38,3 +38,17 @@ export function resume() { arguments, ); } + +export function renderToPipeableStream() { + return require('./src/server/react-dom-server.bun').renderToPipeableStream.apply( + this, + arguments, + ); +} + +export function resumeToPipeableStream() { + return require('./src/server/react-dom-server.bun').resumeToPipeableStream.apply( + this, + arguments, + ); +} diff --git a/packages/react-dom/src/server/react-dom-server.bun.js b/packages/react-dom/src/server/react-dom-server.bun.js index 5ca420c2305ec..17c9c1f465aca 100644 --- a/packages/react-dom/src/server/react-dom-server.bun.js +++ b/packages/react-dom/src/server/react-dom-server.bun.js @@ -8,3 +8,14 @@ */ export * from './ReactDOMFizzServerBun.js'; +export { + renderToPipeableStream, + resumeToPipeableStream, + resume, +} from './ReactDOMFizzServerNode.js'; +export { + prerenderToNodeStream, + prerender, + resumeAndPrerenderToNodeStream, + resumeAndPrerender, +} from './ReactDOMFizzStaticNode.js'; diff --git a/packages/react-dom/src/server/react-dom-server.bun.stable.js b/packages/react-dom/src/server/react-dom-server.bun.stable.js index 4d17773002f7f..50c83508ba909 100644 --- a/packages/react-dom/src/server/react-dom-server.bun.stable.js +++ b/packages/react-dom/src/server/react-dom-server.bun.stable.js @@ -8,3 +8,14 @@ */ export {renderToReadableStream, version} from './ReactDOMFizzServerBun.js'; +export { + renderToPipeableStream, + resume, + resumeToPipeableStream, +} from './ReactDOMFizzServerNode.js'; +export { + prerenderToNodeStream, + prerender, + resumeAndPrerenderToNodeStream, + resumeAndPrerender, +} from './ReactDOMFizzStaticNode.js'; diff --git a/packages/react-native-renderer/index.js b/packages/react-native-renderer/index.js index ecc5e58c3d8d6..131c066ff44b6 100644 --- a/packages/react-native-renderer/index.js +++ b/packages/react-native-renderer/index.js @@ -12,4 +12,5 @@ import * as ReactNative from './src/ReactNativeRenderer'; // Assert that the exports line up with the type we're going to expose. (ReactNative: ReactNativeType); +// TODO: Delete the legacy renderer, only Fabric is used now. export * from './src/ReactNativeRenderer'; diff --git a/packages/react-native-renderer/src/__tests__/ReactFabricAndNative-test.internal.js b/packages/react-native-renderer/src/__tests__/ReactFabricAndNative-test.internal.js index 4e0fcad9c80c3..0b2f46b4d5dca 100644 --- a/packages/react-native-renderer/src/__tests__/ReactFabricAndNative-test.internal.js +++ b/packages/react-native-renderer/src/__tests__/ReactFabricAndNative-test.internal.js @@ -40,6 +40,7 @@ describe('created with ReactFabric called with ReactNative', () => { require('react-native/Libraries/ReactPrivate/ReactNativePrivateInterface').getNativeTagFromPublicInstance; }); + // @gate !disableLegacyMode it('find Fabric instances with the RN renderer', () => { const View = createReactNativeComponentClass('RCTView', () => ({ validAttributes: {title: true}, @@ -60,6 +61,7 @@ describe('created with ReactFabric called with ReactNative', () => { expect(getNativeTagFromPublicInstance(instance)).toBe(2); }); + // @gate !disableLegacyMode it('find Fabric nodes with the RN renderer', () => { const View = createReactNativeComponentClass('RCTView', () => ({ validAttributes: {title: true}, @@ -80,6 +82,7 @@ describe('created with ReactFabric called with ReactNative', () => { expect(handle).toBe(2); }); + // @gate !disableLegacyMode it('dispatches commands on Fabric nodes with the RN renderer', () => { nativeFabricUIManager.dispatchCommand.mockClear(); const View = createReactNativeComponentClass('RCTView', () => ({ @@ -101,6 +104,7 @@ describe('created with ReactFabric called with ReactNative', () => { expect(UIManager.dispatchViewManagerCommand).not.toBeCalled(); }); + // @gate !disableLegacyMode it('dispatches sendAccessibilityEvent on Fabric nodes with the RN renderer', () => { nativeFabricUIManager.sendAccessibilityEvent.mockClear(); const View = createReactNativeComponentClass('RCTView', () => ({ @@ -143,6 +147,7 @@ describe('created with ReactNative called with ReactFabric', () => { .ReactNativeViewConfigRegistry.register; }); + // @gate !disableLegacyMode it('find Paper instances with the Fabric renderer', () => { const View = createReactNativeComponentClass('RCTView', () => ({ validAttributes: {title: true}, @@ -163,6 +168,7 @@ describe('created with ReactNative called with ReactFabric', () => { expect(instance._nativeTag).toBe(3); }); + // @gate !disableLegacyMode it('find Paper nodes with the Fabric renderer', () => { const View = createReactNativeComponentClass('RCTView', () => ({ validAttributes: {title: true}, @@ -183,6 +189,7 @@ describe('created with ReactNative called with ReactFabric', () => { expect(handle).toBe(3); }); + // @gate !disableLegacyMode it('dispatches commands on Paper nodes with the Fabric renderer', () => { UIManager.dispatchViewManagerCommand.mockReset(); const View = createReactNativeComponentClass('RCTView', () => ({ @@ -205,6 +212,7 @@ describe('created with ReactNative called with ReactFabric', () => { expect(nativeFabricUIManager.dispatchCommand).not.toBeCalled(); }); + // @gate !disableLegacyMode it('dispatches sendAccessibilityEvent on Paper nodes with the Fabric renderer', () => { ReactNativePrivateInterface.legacySendAccessibilityEvent.mockReset(); const View = createReactNativeComponentClass('RCTView', () => ({ diff --git a/packages/react-reconciler/README.md b/packages/react-reconciler/README.md index b30387c591f16..82080513a459c 100644 --- a/packages/react-reconciler/README.md +++ b/packages/react-reconciler/README.md @@ -59,7 +59,7 @@ The examples in the React repository are declared a bit differently than a third * [React ART](https://github.com/facebook/react/blob/main/packages/react-art/src/ReactART.js) and its [host config](https://github.com/facebook/react/blob/main/packages/react-art/src/ReactFiberConfigART.js) * [React DOM](https://github.com/facebook/react/blob/main/packages/react-dom/src/client/ReactDOM.js) and its [host config](https://github.com/facebook/react/blob/main/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js) -* [React Native](https://github.com/facebook/react/blob/main/packages/react-native-renderer/src/ReactNativeRenderer.js) and its [host config](https://github.com/facebook/react/blob/main/packages/react-native-renderer/src/ReactFiberConfigNative.js) +* [React Native](https://github.com/facebook/react/blob/main/packages/react-native-renderer/src/ReactFabric.js) and its [host config](https://github.com/facebook/react/blob/main/packages/react-native-renderer/src/ReactFiberConfigFabric.js) If these links break please file an issue and we’ll fix them. They intentionally link to the latest versions since the API is still evolving. If you have more questions please file an issue and we’ll try to help! diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js index a47c9b086a18c..9e024107fc5dd 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js @@ -2196,4 +2196,29 @@ describe('ReactFlightDOMEdge', () => { 'Switched to client rendering because the server rendering errored:\n\nssr-throw', ); }); + + it('should properly resolve with deduped objects', async () => { + const obj = {foo: 'hi'}; + + function Test(props) { + return props.obj.foo; + } + + const root = { + obj: obj, + node: , + }; + + const stream = ReactServerDOMServer.renderToReadableStream(root); + + const response = ReactServerDOMClient.createFromReadableStream(stream, { + serverConsumerManifest: { + moduleMap: null, + moduleLoading: null, + }, + }); + + const result = await response; + expect(result).toEqual({obj: obj, node: 'hi'}); + }); }); diff --git a/packages/react-server/src/ReactServerStreamConfigBun.js b/packages/react-server/src/ReactServerStreamConfigBun.js index ef53ca96236bc..a9079bee43a65 100644 --- a/packages/react-server/src/ReactServerStreamConfigBun.js +++ b/packages/react-server/src/ReactServerStreamConfigBun.js @@ -9,13 +9,22 @@ /* global Bun */ +import type {Writable} from 'stream'; + type BunReadableStreamController = ReadableStreamController & { end(): mixed, write(data: Chunk | BinaryChunk): void, error(error: Error): void, flush?: () => void, }; -export type Destination = BunReadableStreamController; + +interface MightBeFlushable { + flush?: () => void; +} + +export type Destination = + | BunReadableStreamController + | (Writable & MightBeFlushable); export type PrecomputedChunk = string; export opaque type Chunk = string; @@ -46,6 +55,7 @@ export function writeChunk( return; } + // $FlowFixMe[incompatible-call]: write() is compatible with both types in Bun destination.write(chunk); } @@ -53,6 +63,7 @@ export function writeChunkAndReturn( destination: Destination, chunk: PrecomputedChunk | Chunk | BinaryChunk, ): boolean { + // $FlowFixMe[incompatible-call]: write() is compatible with both types in Bun return !!destination.write(chunk); } @@ -86,11 +97,21 @@ export function byteLengthOfBinaryChunk(chunk: BinaryChunk): number { } export function closeWithError(destination: Destination, error: mixed): void { + // $FlowFixMe[incompatible-use] // $FlowFixMe[method-unbinding] if (typeof destination.error === 'function') { // $FlowFixMe[incompatible-call]: This is an Error object or the destination accepts other types. destination.error(error); - } else { + + // $FlowFixMe[incompatible-use] + // $FlowFixMe[method-unbinding] + } else if (typeof destination.destroy === 'function') { + // $FlowFixMe[incompatible-call]: This is an Error object or the destination accepts other types. + destination.destroy(error); + + // $FlowFixMe[incompatible-use] + // $FlowFixMe[method-unbinding] + } else if (typeof destination.close === 'function') { // Earlier implementations doesn't support this method. In that environment you're // supposed to throw from a promise returned but we don't return a promise in our // approach. We could fork this implementation but this is environment is an edge @@ -101,7 +122,7 @@ export function closeWithError(destination: Destination, error: mixed): void { } } -export function createFastHash(input: string): string | number { +export function createFastHash(input: string): number { return Bun.hash(input); } diff --git a/packages/shared/forks/ReactFeatureFlags.native-fb.js b/packages/shared/forks/ReactFeatureFlags.native-fb.js index afecaa685faa5..dd0bd8624f326 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.native-fb.js @@ -35,7 +35,7 @@ export const disableCommentsAsDOMContainers: boolean = true; export const disableInputAttributeSyncing: boolean = false; export const disableLegacyContext: boolean = false; export const disableLegacyContextForFunctionComponents: boolean = false; -export const disableLegacyMode: boolean = false; +export const disableLegacyMode: boolean = true; export const disableSchedulerTimeoutInWorkLoop: boolean = false; export const disableTextareaChildren: boolean = false; export const enableAsyncDebugInfo: boolean = false; diff --git a/packages/shared/forks/ReactFeatureFlags.native-oss.js b/packages/shared/forks/ReactFeatureFlags.native-oss.js index 7cabeb526a2bc..555307cef00a7 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-oss.js +++ b/packages/shared/forks/ReactFeatureFlags.native-oss.js @@ -21,7 +21,7 @@ export const disableCommentsAsDOMContainers: boolean = true; export const disableInputAttributeSyncing: boolean = false; export const disableLegacyContext: boolean = true; export const disableLegacyContextForFunctionComponents: boolean = true; -export const disableLegacyMode: boolean = false; +export const disableLegacyMode: boolean = true; export const disableSchedulerTimeoutInWorkLoop: boolean = false; export const disableTextareaChildren: boolean = false; export const enableAsyncDebugInfo: boolean = false; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js index eb56da603d309..0ff044250cb2e 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js @@ -16,7 +16,7 @@ export const disableCommentsAsDOMContainers = true; export const disableInputAttributeSyncing = false; export const disableLegacyContext = false; export const disableLegacyContextForFunctionComponents = false; -export const disableLegacyMode = false; +export const disableLegacyMode = true; export const disableSchedulerTimeoutInWorkLoop = false; export const disableTextareaChildren = false; export const enableAsyncDebugInfo = false; diff --git a/scripts/rollup/bundles.js b/scripts/rollup/bundles.js index 0a7b17ec2cc7f..c4176099b7622 100644 --- a/scripts/rollup/bundles.js +++ b/scripts/rollup/bundles.js @@ -405,7 +405,7 @@ const bundles = [ global: 'ReactDOMServer', minifyWithProdErrorCodes: false, wrapWithModuleBoundaries: false, - externals: ['react', 'react-dom'], + externals: ['react', 'react-dom', 'crypto', 'stream', 'util'], }, /******* React DOM Fizz Server External Runtime *******/ @@ -819,42 +819,6 @@ const bundles = [ }), }, - /******* React Native *******/ - { - bundleTypes: __EXPERIMENTAL__ - ? [] - : [RN_FB_DEV, RN_FB_PROD, RN_FB_PROFILING], - moduleType: RENDERER, - entry: 'react-native-renderer', - global: 'ReactNativeRenderer', - externals: ['react-native', 'ReactNativeInternalFeatureFlags'], - minifyWithProdErrorCodes: false, - wrapWithModuleBoundaries: true, - babel: opts => - Object.assign({}, opts, { - plugins: opts.plugins.concat([ - [require.resolve('@babel/plugin-transform-classes'), {loose: true}], - ]), - }), - }, - { - bundleTypes: [RN_OSS_DEV, RN_OSS_PROD, RN_OSS_PROFILING], - moduleType: RENDERER, - entry: 'react-native-renderer', - global: 'ReactNativeRenderer', - // ReactNativeInternalFeatureFlags temporary until we land enableRemoveConsolePatches. - // Needs to be done before the next RN OSS release. - externals: ['react-native', 'ReactNativeInternalFeatureFlags'], - minifyWithProdErrorCodes: false, - wrapWithModuleBoundaries: true, - babel: opts => - Object.assign({}, opts, { - plugins: opts.plugins.concat([ - [require.resolve('@babel/plugin-transform-classes'), {loose: true}], - ]), - }), - }, - /******* React Native Fabric *******/ { bundleTypes: __EXPERIMENTAL__ diff --git a/scripts/rollup/shims/react-native/ReactNative.js b/scripts/rollup/shims/react-native/ReactNative.js index 82c062bb85123..4e7ab19ae6e62 100644 --- a/scripts/rollup/shims/react-native/ReactNative.js +++ b/scripts/rollup/shims/react-native/ReactNative.js @@ -14,6 +14,7 @@ import type {ReactNativeType} from './ReactNativeTypes'; let ReactNative: ReactNativeType; +// TODO: Delete the legacy renderer. Only ReactFabric is used now. if (__DEV__) { ReactNative = require('../implementations/ReactNativeRenderer-dev'); } else { diff --git a/scripts/shared/inlinedHostConfigs.js b/scripts/shared/inlinedHostConfigs.js index 801060c4c5455..ebdbf6cf52f82 100644 --- a/scripts/shared/inlinedHostConfigs.js +++ b/scripts/shared/inlinedHostConfigs.js @@ -250,6 +250,8 @@ module.exports = [ 'react-dom/server.bun', 'react-dom/src/server/react-dom-server.bun', 'react-dom/src/server/ReactDOMFizzServerBun.js', + 'react-dom/src/server/ReactDOMFizzServerNode.js', + 'react-dom/src/server/ReactDOMFizzStaticNode.js', 'react-dom-bindings', 'react-dom-bindings/src/server/ReactDOMFlightServerHostDispatcher.js', 'react-dom-bindings/src/server/ReactFlightServerConfigDOM.js', diff --git a/scripts/tasks/generate-changelog/args.js b/scripts/tasks/generate-changelog/args.js new file mode 100644 index 0000000000000..cee42f24ecd0d --- /dev/null +++ b/scripts/tasks/generate-changelog/args.js @@ -0,0 +1,128 @@ +'use strict'; + +const semver = require('semver'); +const yargs = require('yargs/yargs'); + +const {stablePackages} = require('../../../ReactVersions'); +const {isCommandAvailable} = require('./utils'); + +function parseArgs(argv) { + const parser = yargs(argv) + .usage( + 'Usage: yarn generate-changelog [--codex|--claude] [--debug] [--format ] [ ...]' + ) + .example( + '$0 --codex eslint-plugin-react-hooks@7.0.1', + 'Generate changelog for a single package using Codex.' + ) + .example( + '$0 --claude react@19.3 react-dom@19.3', + 'Generate changelog entries for multiple packages using Claude.' + ) + .example( + '$0 --codex', + 'Generate changelog for all stable packages using recorded versions.' + ) + .option('codex', { + type: 'boolean', + describe: 'Use Codex for commit summarization.', + }) + .option('claude', { + type: 'boolean', + describe: 'Use Claude for commit summarization.', + }) + .option('debug', { + type: 'boolean', + describe: 'Enable verbose debug logging.', + default: false, + }) + .option('format', { + type: 'string', + describe: 'Output format for the generated changelog.', + choices: ['text', 'csv', 'json'], + default: 'text', + }) + .help('help') + .alias('h', 'help') + .version(false) + .parserConfiguration({ + 'parse-numbers': false, + 'parse-positional-numbers': false, + }); + + const args = parser.scriptName('generate-changelog').parse(); + const packageSpecs = []; + const debug = !!args.debug; + const format = args.format || 'text'; + let summarizer = null; + + if (args.codex && args.claude) { + throw new Error('Choose either --codex or --claude, not both.'); + } + if (args.codex) { + summarizer = 'codex'; + } else if (args.claude) { + summarizer = 'claude'; + } + + const positionalArgs = Array.isArray(args._) ? args._ : []; + for (let i = 0; i < positionalArgs.length; i++) { + const token = String(positionalArgs[i]).trim(); + if (!token) { + continue; + } + + const atIndex = token.lastIndexOf('@'); + if (atIndex <= 0 || atIndex === token.length - 1) { + throw new Error(`Invalid package specification: ${token}`); + } + + const packageName = token.slice(0, atIndex); + const versionText = token.slice(atIndex + 1); + const validVersion = + semver.valid(versionText) || semver.valid(semver.coerce(versionText)); + if (!validVersion) { + throw new Error(`Invalid version for ${packageName}: ${versionText}`); + } + + packageSpecs.push({ + name: packageName, + version: validVersion, + displayVersion: versionText, + }); + } + + if (packageSpecs.length === 0) { + Object.keys(stablePackages).forEach(pkgName => { + const versionText = stablePackages[pkgName]; + const validVersion = semver.valid(versionText); + if (!validVersion) { + throw new Error( + `Invalid stable version configured for ${pkgName}: ${versionText}` + ); + } + packageSpecs.push({ + name: pkgName, + version: validVersion, + displayVersion: versionText, + }); + }); + } + + if (summarizer && !isCommandAvailable(summarizer)) { + throw new Error( + `Requested summarizer "${summarizer}" is not available on the PATH.` + ); + } + + return { + debug, + format, + summarizer, + packageSpecs, + }; +} + +module.exports = { + parseArgs, +}; diff --git a/scripts/tasks/generate-changelog/data.js b/scripts/tasks/generate-changelog/data.js new file mode 100644 index 0000000000000..17e25e0a7639f --- /dev/null +++ b/scripts/tasks/generate-changelog/data.js @@ -0,0 +1,190 @@ +'use strict'; + +const https = require('https'); +const path = require('path'); + +const {execFileAsync, repoRoot} = require('./utils'); + +async function fetchNpmInfo(packageName, {log}) { + const npmArgs = ['view', `${packageName}@latest`, '--json']; + const options = {cwd: repoRoot, maxBuffer: 10 * 1024 * 1024}; + log(`Fetching npm info for ${packageName}...`); + const {stdout} = await execFileAsync('npm', npmArgs, options); + + let data = stdout.trim(); + if (!data) { + throw new Error(`npm view returned empty result for ${packageName}`); + } + + let info = JSON.parse(data); + if (Array.isArray(info)) { + info = info[info.length - 1]; + } + + const version = info.version || info['dist-tags']?.latest; + let gitHead = info.gitHead || null; + + if (!gitHead) { + const gitHeadResult = await execFileAsync( + 'npm', + ['view', `${packageName}@${version}`, 'gitHead'], + {cwd: repoRoot, maxBuffer: 1024 * 1024} + ); + const possibleGitHead = gitHeadResult.stdout.trim(); + if ( + possibleGitHead && + possibleGitHead !== 'undefined' && + possibleGitHead !== 'null' + ) { + log(`Found gitHead for ${packageName}@${version}: ${possibleGitHead}`); + gitHead = possibleGitHead; + } + } + + if (!version) { + throw new Error( + `Unable to determine latest published version for ${packageName}` + ); + } + if (!gitHead) { + throw new Error( + `Unable to determine git commit for ${packageName}@${version}` + ); + } + + return { + publishedVersion: version, + gitHead, + }; +} + +async function collectCommitsSince(packageName, sinceGitSha, {log}) { + log(`Collecting commits for ${packageName} since ${sinceGitSha}...`); + await execFileAsync('git', ['cat-file', '-e', `${sinceGitSha}^{commit}`], { + cwd: repoRoot, + }); + const {stdout} = await execFileAsync( + 'git', + [ + 'rev-list', + '--reverse', + `${sinceGitSha}..HEAD`, + '--', + path.posix.join('packages', packageName), + ], + {cwd: repoRoot, maxBuffer: 10 * 1024 * 1024} + ); + + return stdout + .trim() + .split('\n') + .map(line => line.trim()) + .filter(Boolean); +} + +async function loadCommitDetails(sha, {log}) { + log(`Loading commit details for ${sha}...`); + const format = ['%H', '%s', '%an', '%ae', '%ct', '%B'].join('%n'); + const {stdout} = await execFileAsync( + 'git', + ['show', '--quiet', `--format=${format}`, sha], + {cwd: repoRoot, maxBuffer: 10 * 1024 * 1024} + ); + + const [commitSha, subject, authorName, authorEmail, timestamp, ...rest] = + stdout.split('\n'); + const body = rest.join('\n').trim(); + + return { + sha: commitSha.trim(), + subject: subject.trim(), + authorName: authorName.trim(), + authorEmail: authorEmail.trim(), + timestamp: +timestamp.trim() || 0, + body, + }; +} + +function extractPrNumber(subject, body) { + const patterns = [ + /\(#(\d+)\)/, + /https:\/\/github\.com\/facebook\/react\/pull\/(\d+)/, + ]; + + for (let i = 0; i < patterns.length; i++) { + const pattern = patterns[i]; + const subjectMatch = subject && subject.match(pattern); + if (subjectMatch) { + return subjectMatch[1]; + } + const bodyMatch = body && body.match(pattern); + if (bodyMatch) { + return bodyMatch[1]; + } + } + + return null; +} + +async function fetchPullRequestMetadata(prNumber, {log}) { + log(`Fetching PR metadata for #${prNumber}...`); + const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN || null; + const requestOptions = { + hostname: 'api.github.com', + path: `/repos/facebook/react/pulls/${prNumber}`, + method: 'GET', + headers: { + 'User-Agent': 'generate-changelog-script', + Accept: 'application/vnd.github+json', + }, + }; + if (token) { + requestOptions.headers.Authorization = `Bearer ${token}`; + } + + return new Promise(resolve => { + const req = https.request(requestOptions, res => { + let raw = ''; + res.on('data', chunk => { + raw += chunk; + }); + res.on('end', () => { + if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) { + try { + const json = JSON.parse(raw); + resolve({ + authorLogin: json.user?.login || null, + }); + } catch (error) { + process.stderr.write( + `Warning: unable to parse GitHub response for PR #${prNumber}: ${error.message}\n` + ); + resolve(null); + } + } else { + process.stderr.write( + `Warning: GitHub API request failed for PR #${prNumber} with status ${res.statusCode}\n` + ); + resolve(null); + } + }); + }); + + req.on('error', error => { + process.stderr.write( + `Warning: GitHub API request errored for PR #${prNumber}: ${error.message}\n` + ); + resolve(null); + }); + + req.end(); + }); +} + +module.exports = { + fetchNpmInfo, + collectCommitsSince, + loadCommitDetails, + extractPrNumber, + fetchPullRequestMetadata, +}; diff --git a/scripts/tasks/generate-changelog/formatters.js b/scripts/tasks/generate-changelog/formatters.js new file mode 100644 index 0000000000000..49ed2a6a1066b --- /dev/null +++ b/scripts/tasks/generate-changelog/formatters.js @@ -0,0 +1,228 @@ +'use strict'; + +const {toCsvRow} = require('./utils'); + +const NO_CHANGES_MESSAGE = 'No changes since the last release.'; + +function buildChangelogEntries({ + packageSpecs, + commitsByPackage, + summariesByPackage, + prMetadata, +}) { + const entries = []; + + for (let i = 0; i < packageSpecs.length; i++) { + const spec = packageSpecs[i]; + const version = spec.displayVersion || spec.version; + const commitsForPackage = commitsByPackage.get(spec.name) || []; + + if (commitsForPackage.length === 0) { + entries.push({ + package: spec.name, + version, + hasChanges: false, + note: NO_CHANGES_MESSAGE, + commits: [], + }); + continue; + } + + const summaryMap = summariesByPackage.get(spec.name) || new Map(); + const commitEntries = commitsForPackage.map(commit => { + let summary = summaryMap.get(commit.sha) || commit.subject; + if (commit.prNumber) { + const prPattern = new RegExp(`\\s*\\(#${commit.prNumber}\\)$`); + summary = summary.replace(prPattern, '').trim(); + } + + const commitSha = commit.sha; + const commitUrl = `https://github.com/facebook/react/commit/${commitSha}`; + const prNumber = commit.prNumber || null; + const prUrl = prNumber + ? `https://github.com/facebook/react/pull/${prNumber}` + : null; + const prEntry = prNumber ? prMetadata.get(prNumber) : null; + + const authorLogin = prEntry?.authorLogin || null; + const authorName = commit.authorName || null; + const authorEmail = commit.authorEmail || null; + + let authorUrl = null; + let authorDisplay = authorName || 'unknown author'; + + if (authorLogin) { + authorUrl = `https://github.com/${authorLogin}`; + authorDisplay = `[@${authorLogin}](${authorUrl})`; + } else if (authorName && authorName.startsWith('@')) { + const username = authorName.slice(1); + authorUrl = `https://github.com/${username}`; + authorDisplay = `[@${username}](${authorUrl})`; + } + + const referenceType = prNumber ? 'pr' : 'commit'; + const referenceId = prNumber ? `#${prNumber}` : commitSha.slice(0, 7); + const referenceUrl = prNumber ? prUrl : commitUrl; + const referenceLabel = prNumber + ? `[#${prNumber}](${prUrl})` + : `commit ${commitSha.slice(0, 7)}`; + + return { + summary, + prNumber, + prUrl, + commitSha, + commitUrl, + author: { + login: authorLogin, + name: authorName, + email: authorEmail, + url: authorUrl, + display: authorDisplay, + }, + reference: { + type: referenceType, + id: referenceId, + url: referenceUrl, + label: referenceLabel, + }, + }; + }); + + entries.push({ + package: spec.name, + version, + hasChanges: true, + note: null, + commits: commitEntries, + }); + } + + return entries; +} + +function renderChangelog(entries, format) { + if (format === 'text') { + const lines = []; + for (let i = 0; i < entries.length; i++) { + const entry = entries[i]; + lines.push(`## ${entry.package}@${entry.version}`); + if (!entry.hasChanges) { + lines.push(`* ${entry.note}`); + lines.push(''); + continue; + } + + entry.commits.forEach(commit => { + lines.push( + `* ${commit.summary} (${commit.reference.label} by ${commit.author.display})` + ); + }); + lines.push(''); + } + + while (lines.length && lines[lines.length - 1] === '') { + lines.pop(); + } + + return lines.join('\n'); + } + + if (format === 'csv') { + const header = [ + 'package', + 'version', + 'summary', + 'reference_type', + 'reference_id', + 'reference_url', + 'author_name', + 'author_login', + 'author_url', + 'author_email', + 'commit_sha', + 'commit_url', + ]; + const rows = [header]; + + entries.forEach(entry => { + if (!entry.hasChanges) { + rows.push([ + entry.package, + entry.version, + entry.note, + '', + '', + '', + '', + '', + '', + '', + '', + '', + ]); + return; + } + + entry.commits.forEach(commit => { + const authorName = + commit.author.name || + (commit.author.login ? `@${commit.author.login}` : 'unknown author'); + rows.push([ + entry.package, + entry.version, + commit.summary, + commit.reference.type, + commit.reference.id, + commit.reference.url, + authorName, + commit.author.login || '', + commit.author.url || '', + commit.author.email || '', + commit.commitSha, + commit.commitUrl, + ]); + }); + }); + + return rows.map(toCsvRow).join('\n'); + } + + if (format === 'json') { + const payload = entries.map(entry => ({ + package: entry.package, + version: entry.version, + hasChanges: entry.hasChanges, + note: entry.hasChanges ? undefined : entry.note, + commits: entry.commits.map(commit => ({ + summary: commit.summary, + prNumber: commit.prNumber, + prUrl: commit.prUrl, + commitSha: commit.commitSha, + commitUrl: commit.commitUrl, + author: { + login: commit.author.login, + name: commit.author.name, + email: commit.author.email, + url: commit.author.url, + display: commit.author.display, + }, + reference: { + type: commit.reference.type, + id: commit.reference.id, + url: commit.reference.url, + label: commit.reference.label, + }, + })), + })); + + return JSON.stringify(payload, null, 2); + } + + throw new Error(`Unsupported format: ${format}`); +} + +module.exports = { + buildChangelogEntries, + renderChangelog, +}; diff --git a/scripts/tasks/generate-changelog/index.js b/scripts/tasks/generate-changelog/index.js new file mode 100644 index 0000000000000..816ad7959bfbe --- /dev/null +++ b/scripts/tasks/generate-changelog/index.js @@ -0,0 +1,158 @@ +'use strict'; + +const {stablePackages} = require('../../../ReactVersions'); +const {parseArgs} = require('./args'); +const { + fetchNpmInfo, + collectCommitsSince, + loadCommitDetails, + extractPrNumber, + fetchPullRequestMetadata, +} = require('./data'); +const {summarizePackages} = require('./summaries'); +const {buildChangelogEntries, renderChangelog} = require('./formatters'); +const {noopLogger} = require('./utils'); + +async function main() { + const {packageSpecs, summarizer, debug, format} = parseArgs( + process.argv.slice(2) + ); + const log = debug + ? (...args) => console.log('[generate-changelog]', ...args) + : noopLogger; + + const allStablePackages = Object.keys(stablePackages); + const packageTargets = new Map(); + for (let i = 0; i < packageSpecs.length; i++) { + const spec = packageSpecs[i]; + if (!allStablePackages.includes(spec.name)) { + throw new Error( + `Package "${spec.name}" is not listed in stablePackages.` + ); + } + if (packageTargets.has(spec.name)) { + throw new Error(`Package "${spec.name}" was specified more than once.`); + } + packageTargets.set(spec.name, spec); + } + + const targetPackages = packageSpecs.map(spec => spec.name); + log( + `Starting changelog generation for: ${packageSpecs + .map(spec => `${spec.name}@${spec.displayVersion || spec.version}`) + .join(', ')}` + ); + + const packageInfoMap = new Map(); + const packageInfoResults = await Promise.all( + targetPackages.map(async pkg => { + const info = await fetchNpmInfo(pkg, {log}); + return {pkg, info}; + }) + ); + for (let i = 0; i < packageInfoResults.length; i++) { + const entry = packageInfoResults[i]; + packageInfoMap.set(entry.pkg, entry.info); + } + + const commitPackagesMap = new Map(); + const commitCollections = await Promise.all( + targetPackages.map(async pkg => { + const {gitHead} = packageInfoMap.get(pkg); + const commits = await collectCommitsSince(pkg, gitHead, {log}); + log(`Package ${pkg} has ${commits.length} commit(s) since ${gitHead}.`); + return {pkg, commits}; + }) + ); + for (let i = 0; i < commitCollections.length; i++) { + const entry = commitCollections[i]; + const pkg = entry.pkg; + const commits = entry.commits; + for (let j = 0; j < commits.length; j++) { + const sha = commits[j]; + if (!commitPackagesMap.has(sha)) { + commitPackagesMap.set(sha, new Set()); + } + commitPackagesMap.get(sha).add(pkg); + } + } + log(`Found ${commitPackagesMap.size} commits touching target packages.`); + + if (commitPackagesMap.size === 0) { + console.log('No commits found for the selected packages.'); + return; + } + + const commitDetails = await Promise.all( + Array.from(commitPackagesMap.entries()).map( + async ([sha, packagesTouched]) => { + const detail = await loadCommitDetails(sha, {log}); + detail.packages = packagesTouched; + detail.prNumber = extractPrNumber(detail.subject, detail.body); + return detail; + } + ) + ); + + commitDetails.sort((a, b) => a.timestamp - b.timestamp); + log(`Ordered ${commitDetails.length} commit(s) chronologically.`); + + const commitsByPackage = new Map(); + commitDetails.forEach(commit => { + commit.packages.forEach(pkgName => { + if (!commitsByPackage.has(pkgName)) { + commitsByPackage.set(pkgName, []); + } + commitsByPackage.get(pkgName).push(commit); + }); + }); + + const uniquePrNumbers = Array.from( + new Set(commitDetails.map(commit => commit.prNumber).filter(Boolean)) + ); + log(`Identified ${uniquePrNumbers.length} unique PR number(s).`); + + const prMetadata = new Map(); + log(`Summarizer selected: ${summarizer || 'none (using commit titles)'}`); + const prMetadataResults = await Promise.all( + uniquePrNumbers.map(async prNumber => { + const meta = await fetchPullRequestMetadata(prNumber, {log}); + return {prNumber, meta}; + }) + ); + for (let i = 0; i < prMetadataResults.length; i++) { + const entry = prMetadataResults[i]; + if (entry.meta) { + prMetadata.set(entry.prNumber, entry.meta); + } + } + log(`Fetched metadata for ${prMetadata.size} PR(s).`); + + const summariesByPackage = await summarizePackages({ + summarizer, + packageSpecs, + packageTargets, + commitsByPackage, + log, + }); + + const changelogEntries = buildChangelogEntries({ + packageSpecs, + commitsByPackage, + summariesByPackage, + prMetadata, + }); + + log('Generated changelog sections.'); + const output = renderChangelog(changelogEntries, format); + console.log(output); +} + +if (require.main === module) { + main().catch(error => { + process.stderr.write(`${error.message}\n`); + process.exit(1); + }); +} else { + module.exports = main; +} diff --git a/scripts/tasks/generate-changelog/summaries.js b/scripts/tasks/generate-changelog/summaries.js new file mode 100644 index 0000000000000..d0b73851efd8c --- /dev/null +++ b/scripts/tasks/generate-changelog/summaries.js @@ -0,0 +1,306 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const {execFileAsync, repoRoot, noopLogger} = require('./utils'); + +function readChangelogSnippet(preferredPackage) { + const cacheKey = + preferredPackage === 'eslint-plugin-react-hooks' + ? preferredPackage + : 'root'; + if (!readChangelogSnippet.cache) { + readChangelogSnippet.cache = new Map(); + } + const cache = readChangelogSnippet.cache; + if (cache.has(cacheKey)) { + return cache.get(cacheKey); + } + + const targetPath = + preferredPackage === 'eslint-plugin-react-hooks' + ? path.join( + repoRoot, + 'packages', + 'eslint-plugin-react-hooks', + 'CHANGELOG.md' + ) + : path.join(repoRoot, 'CHANGELOG.md'); + + let content = ''; + try { + content = fs.readFileSync(targetPath, 'utf8'); + } catch { + content = ''; + } + + const snippet = content.slice(0, 4000); + cache.set(cacheKey, snippet); + return snippet; +} + +function sanitizeSummary(text) { + if (!text) { + return ''; + } + + const trimmed = text.trim(); + const withoutBullet = trimmed.replace(/^([-*]\s+|\d+\s*[\.)]\s+)/, ''); + + return withoutBullet.replace(/\s+/g, ' ').trim(); +} + +async function summarizePackages({ + summarizer, + packageSpecs, + packageTargets, + commitsByPackage, + log, +}) { + const summariesByPackage = new Map(); + if (!summarizer) { + packageSpecs.forEach(spec => { + const commits = commitsByPackage.get(spec.name) || []; + const summaryMap = new Map(); + for (let i = 0; i < commits.length; i++) { + const commit = commits[i]; + summaryMap.set(commit.sha, commit.subject); + } + summariesByPackage.set(spec.name, summaryMap); + }); + return summariesByPackage; + } + + const tasks = packageSpecs.map(spec => { + const commits = commitsByPackage.get(spec.name) || []; + return summarizePackageCommits({ + summarizer, + spec, + commits, + packageTargets, + allPackageSpecs: packageSpecs, + log, + }); + }); + + const results = await Promise.all(tasks); + results.forEach(entry => { + summariesByPackage.set(entry.packageName, entry.summaries); + }); + return summariesByPackage; +} + +async function summarizePackageCommits({ + summarizer, + spec, + commits, + packageTargets, + allPackageSpecs, + log, +}) { + const summaries = new Map(); + if (commits.length === 0) { + return {packageName: spec.name, summaries}; + } + + const rootStyle = readChangelogSnippet('root'); + const hooksStyle = readChangelogSnippet('eslint-plugin-react-hooks'); + const targetList = allPackageSpecs.map( + targetSpec => + `${targetSpec.name}@${targetSpec.displayVersion || targetSpec.version}` + ); + const payload = commits.map(commit => { + const packages = Array.from(commit.packages || []).sort(); + const usesHooksStyle = (commit.packages || new Set()).has( + 'eslint-plugin-react-hooks' + ); + const packagesWithVersions = packages.map(pkgName => { + const targetSpec = packageTargets.get(pkgName); + if (!targetSpec) { + return pkgName; + } + return `${pkgName}@${targetSpec.displayVersion || targetSpec.version}`; + }); + return { + sha: commit.sha, + packages, + packagesWithVersions, + style: usesHooksStyle ? 'eslint-plugin-react-hooks' : 'root', + subject: commit.subject, + body: commit.body || '', + }; + }); + + const promptParts = [ + `You are preparing changelog summaries for ${spec.name} ${ + spec.displayVersion || spec.version + }.`, + 'The broader release includes:', + ...targetList.map(line => `- ${line}`), + '', + 'For each commit payload, write a single concise sentence without a leading bullet.', + 'Match the tone and formatting of the provided style samples. Do not mention commit hashes.', + 'Return a JSON array where each element has the shape `{ "sha": "", "summary": "" }`.', + 'The JSON must contain one entry per commit in the same order they are provided.', + 'Use `"root"` style unless the payload specifies `"eslint-plugin-react-hooks"`, in which case use that style sample.', + '', + '--- STYLE: root ---', + rootStyle, + '--- END STYLE ---', + '', + '--- STYLE: eslint-plugin-react-hooks ---', + hooksStyle, + '--- END STYLE ---', + '', + `Commits affecting ${spec.name}:`, + ]; + + payload.forEach((item, index) => { + promptParts.push( + `Commit ${index + 1}:`, + `sha: ${item.sha}`, + `style: ${item.style}`, + `packages: ${item.packagesWithVersions.join(', ') || 'none'}`, + `subject: ${item.subject}`, + 'body:', + item.body || '(empty)', + '' + ); + }); + promptParts.push('Return ONLY the JSON array.', ''); + + const prompt = promptParts.join('\n'); + log( + `Invoking ${summarizer} for ${payload.length} commit summaries targeting ${spec.name}.` + ); + log(`Summarizer prompt length: ${prompt.length} characters.`); + + try { + const raw = await runSummarizer(summarizer, prompt); + log(`Summarizer output length: ${raw.length}`); + const parsed = parseSummariesResponse(raw); + if (!parsed) { + throw new Error('Unable to parse summarizer output.'); + } + parsed.forEach(entry => { + const summary = sanitizeSummary(entry.summary || ''); + if (summary) { + summaries.set(entry.sha, summary); + } + }); + } catch (error) { + if (log !== noopLogger) { + log( + `Warning: failed to summarize commits for ${spec.name} with ${summarizer}. Falling back to subjects. ${error.message}` + ); + if (error && error.stack) { + log(error.stack); + } + } + } + + for (let i = 0; i < commits.length; i++) { + const commit = commits[i]; + if (!summaries.has(commit.sha)) { + summaries.set(commit.sha, commit.subject); + } + } + + log(`Summaries available for ${summaries.size} commit(s) for ${spec.name}.`); + + return {packageName: spec.name, summaries}; +} + +async function runSummarizer(command, prompt) { + const options = {cwd: repoRoot, maxBuffer: 5 * 1024 * 1024}; + + if (command === 'codex') { + const {stdout} = await execFileAsync( + 'codex', + ['exec', '--json', prompt], + options + ); + return parseCodexSummary(stdout); + } + + if (command === 'claude') { + const {stdout} = await execFileAsync('claude', ['-p', prompt], options); + return stripClaudeBanner(stdout); + } + + throw new Error(`Unsupported summarizer command: ${command}`); +} + +function parseCodexSummary(output) { + let last = ''; + const lines = output.split('\n'); + for (let i = 0; i < lines.length; i++) { + const trimmed = lines[i].trim(); + if (!trimmed) { + continue; + } + try { + const event = JSON.parse(trimmed); + if ( + event.type === 'item.completed' && + event.item?.type === 'agent_message' + ) { + last = event.item.text || ''; + } + } catch { + last = trimmed; + } + } + return last || output; +} + +function stripClaudeBanner(text) { + return text + .split('\n') + .filter( + line => + line.trim() !== + 'Claude Code at Meta (https://fburl.com/claude.code.users)' + ) + .join('\n') + .trim(); +} + +function parseSummariesResponse(output) { + const trimmed = output.trim(); + const candidates = trimmed + .split('\n') + .map(line => line.trim()) + .filter(Boolean); + + for (let i = candidates.length - 1; i >= 0; i--) { + const candidate = candidates[i]; + if (!candidate) { + continue; + } + try { + const parsed = JSON.parse(candidate); + if (Array.isArray(parsed)) { + return parsed; + } + } catch { + // Try the next candidate. + } + } + + try { + const parsed = JSON.parse(trimmed); + if (Array.isArray(parsed)) { + return parsed; + } + } catch { + // Fall through. + } + + return null; +} + +module.exports = { + summarizePackages, +}; diff --git a/scripts/tasks/generate-changelog/utils.js b/scripts/tasks/generate-changelog/utils.js new file mode 100644 index 0000000000000..fe4069eca02d2 --- /dev/null +++ b/scripts/tasks/generate-changelog/utils.js @@ -0,0 +1,62 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const {execFile} = require('child_process'); +const {promisify} = require('util'); + +const execFileAsync = promisify(execFile); +const repoRoot = path.resolve(__dirname, '..', '..', '..'); + +function isCommandAvailable(command) { + const paths = (process.env.PATH || '').split(path.delimiter); + const extensions = + process.platform === 'win32' && process.env.PATHEXT + ? process.env.PATHEXT.split(';') + : ['']; + + for (let i = 0; i < paths.length; i++) { + const dir = paths[i]; + if (!dir) { + continue; + } + for (let j = 0; j < extensions.length; j++) { + const ext = extensions[j]; + const fullPath = path.join(dir, `${command}${ext}`); + try { + fs.accessSync(fullPath, fs.constants.X_OK); + return true; + } catch { + // Keep searching. + } + } + } + return false; +} + +function noopLogger() {} + +function escapeCsvValue(value) { + if (value == null) { + return ''; + } + + const stringValue = String(value).replace(/\r?\n|\r/g, ' '); + if (stringValue.includes('"') || stringValue.includes(',')) { + return `"${stringValue.replace(/"/g, '""')}"`; + } + return stringValue; +} + +function toCsvRow(values) { + return values.map(escapeCsvValue).join(','); +} + +module.exports = { + execFileAsync, + repoRoot, + isCommandAvailable, + noopLogger, + escapeCsvValue, + toCsvRow, +};