From e059aa36c07b441c9c21b2087d90f51b3d023783 Mon Sep 17 00:00:00 2001 From: Ruslan Lesiutin Date: Wed, 2 Sep 2026 13:46:26 +0100 Subject: [PATCH 1/5] [DevTools] Remove build dates from extension manifests (#37306) ## Summary The extension build writes `new Date().toLocaleDateString()` into Chrome/Edge `version_name` and into every browser's manifest description. That string changes with the calendar, timezone, and locale, so a Firefox AMO rebuild on another day cannot match the uploaded zip. Stop stamping dates. `version_name` stays the value from the source manifest (still updated by `prepare-release.js` on version bumps). The description still records the commit from #37305. Depends on #37305. Next: #37307. ## How did you test this change? Build-script only. After this, `manifest.json` description is `Created from revision .` and Chrome/Edge `version_name` is the committed version string. --------- Co-authored-by: Ruslan Lesiutin --- packages/react-devtools-extensions/build.js | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/react-devtools-extensions/build.js b/packages/react-devtools-extensions/build.js index 1c8322cd5c98..48c620dd6fd6 100644 --- a/packages/react-devtools-extensions/build.js +++ b/packages/react-devtools-extensions/build.js @@ -97,13 +97,8 @@ const build = async (tempPath, manifestPath, envExtension = {}) => { ); const commit = getGitCommit(); - const dateString = new Date().toLocaleDateString(); const manifest = JSON.parse(readFileSync(copiedManifestPath).toString()); - const versionDateString = `${manifest.version} (${dateString})`; - if (manifest.version_name) { - manifest.version_name = versionDateString; - } - manifest.description += `\n\nCreated from revision ${commit} on ${dateString}.`; + manifest.description += `\n\nCreated from revision ${commit}.`; if (process.env.NODE_ENV === 'development') { // When building the local development version of the From 33b4555488b580c286dccde4ed52395241f1a1f5 Mon Sep 17 00:00:00 2001 From: Ruslan Lesiutin Date: Wed, 2 Sep 2026 13:47:00 +0100 Subject: [PATCH 2/5] [DevTools] Use one commit for extension release inputs (#37307) ## Summary `build-and-test.js` could feed three different commits into one release: `git archive main` (not `HEAD`), an interactively chosen React CI build, and `HEAD` saved as metadata. Firefox source review then could not reproduce the zip. Require a clean tree, resolve `HEAD` once, and use that hash for the source archive, the experimental React download, and the metadata printed for AMO. Drop the prompt that let those diverge. Depends on #37305 and #37306. ## How did you test this change? Build-script only. The release helper now errors on a dirty tree and threads a single `git rev-parse HEAD` into archive, download, and metadata. Co-authored-by: Ruslan Lesiutin --- scripts/devtools/build-and-test.js | 50 ++++++++++++++++-------------- 1 file changed, 26 insertions(+), 24 deletions(-) diff --git a/scripts/devtools/build-and-test.js b/scripts/devtools/build-and-test.js index 78275691aca9..bf56ce9d0c8b 100755 --- a/scripts/devtools/build-and-test.js +++ b/scripts/devtools/build-and-test.js @@ -4,7 +4,6 @@ const chalk = require('chalk'); const {exec} = require('child-process-promise'); -const inquirer = require('inquirer'); const {homedir} = require('os'); const {join, relative} = require('path'); const {DRY_RUN, ROOT_PATH} = require('./configuration'); @@ -49,8 +48,10 @@ async function main() { console.log(chalk.bold.green(' ' + pathToPrint)); }); - const archivePath = await archiveGitRevision(); - const currentCommitHash = await downloadLatestReactBuild(); + await ensureCleanWorkingTree(); + const currentCommitHash = await getCurrentCommitHash(); + const archivePath = await archiveGitRevision(currentCommitHash); + await downloadLatestReactBuild(currentCommitHash); await buildAndTestInlinePackage(); await buildAndTestStandalonePackage(); @@ -61,7 +62,22 @@ async function main() { printFinalInstructions(); } -async function archiveGitRevision() { +async function ensureCleanWorkingTree() { + const status = await execRead('git status --porcelain', {cwd: ROOT_PATH}); + if (status !== '') { + throw new Error('Cannot build a release from a dirty working tree.'); + } +} + +async function getCurrentCommitHash() { + const commitHash = await execRead('git rev-parse HEAD', {cwd: ROOT_PATH}); + if (commitHash === '') { + throw new Error('Failed to get current commit hash'); + } + return commitHash; +} + +async function archiveGitRevision(currentCommitHash) { const desktopPath = join(homedir(), 'Desktop'); const archivePath = join(desktopPath, 'DevTools.tgz'); @@ -69,7 +85,10 @@ async function archiveGitRevision() { console.log(''); if (!DRY_RUN) { - await exec(`git archive main | gzip > ${archivePath}`, {cwd: ROOT_PATH}); + await exec( + `git archive --format=tar.gz --output="${archivePath}" ${currentCommitHash}`, + {cwd: ROOT_PATH} + ); } return archivePath; @@ -181,7 +200,7 @@ async function buildAndTestInlinePackage() { await confirmContinue(); } -async function downloadLatestReactBuild() { +async function downloadLatestReactBuild(currentCommitHash) { const releaseScriptPath = join(ROOT_PATH, 'scripts', 'release'); const installPromise = exec('yarn install', {cwd: releaseScriptPath}); @@ -197,34 +216,17 @@ async function downloadLatestReactBuild() { console.log(''); - const currentCommitHash = (await exec('git rev-parse HEAD')).stdout.trim(); - if (!currentCommitHash) { - throw new Error('Failed to get current commit hash'); - } - - const {commit} = await inquirer.prompt([ - { - type: 'input', - name: 'commit', - message: 'Which React version (commit) should be used?', - default: currentCommitHash, - }, - ]); - console.log(''); - const downloadScriptPath = join( releaseScriptPath, 'download-experimental-build.js' ); const downloadPromise = execRead( - `"${downloadScriptPath}" --commit=${commit}` + `"${downloadScriptPath}" --commit=${currentCommitHash}` ); await logger(downloadPromise, 'Downloading React artifacts from CI.', { estimate: 15000, }); - - return currentCommitHash; } function printFinalInstructions() { From 8f0043721ef508420ae3855d8e9f910b1fec1c85 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Wed, 2 Sep 2026 15:57:40 +0200 Subject: [PATCH 3/5] [Flight] Fix RangeError from exponential debug info growth (#37481) The Flight Client copies the debug info of a referenced chunk into the chunk that references it, so that the receiving chunk records what blocked it. It copies the entries once per reference, and a referenced chunk already carries the entries that it received itself. A response that deduplicates the same object across a chain of rows therefore multiplies the entries at every step. In development the array eventually grows past what the engine can allocate for it, and the client throws `RangeError: Invalid array length`. The receiving chunk now takes each entry only once. The entries are copied by reference and never cloned on this path, so a comparison by identity is exact. The array becomes bounded by the number of distinct entries in the response rather than by a chosen limit. The bookkeeping costs one `Set` per chunk that receives debug info, in development only. The set holds a reference to each entry rather than a copy, so the entries stay shared and nothing about the debug info is duplicated. A chunk receives entries only while it is blocked, so the fix releases the set as soon as the chunk initializes. Debug info still accumulates transitively, which this change does not alter. This change also adds the `!reference.isDebug` guards that #37358 proposes for the element props branch and the default branch of `fulfillReference`. #35795 introduced the rule that a reference resolved during debug info resolution does not transfer, and it left those two branches behind. The guards make that rule hold at every branch. However, those branches reference debug chunks that carry no entries, so the guards change no observed behaviour, and they do not fix the growth in #37343, which comes from references in model chunks. #37343 also reports the call in `getOutlinedModel` as unguarded, which it is not, because #35795 already skips it there. The rest of #37358 deduplicates the entries, which is the right direction, but it scans the receiving array for every candidate, which is quadratic in the size of the debug info. #37359 caps the array at a constant instead, which stops the crash but keeps copying the duplicates and drops debug info once a response passes the cap. **Alternatives Considered** - Tracking the referenced chunks rather than the entries would be cheaper, because it would need one map entry per referenced chunk. It would not be enough, because a chunk can reach the same entry through two paths. A chunk can hold a client reference directly and also reference a chunk that already received the debug info of that client reference. - Recording the last receiving chunk on each entry would be exact while a chunk parses its model, where the transfers into it are consecutive. It would break once transfers into different chunks interleave, and that is the path the reported crash takes. - Turning `_debugInfo` itself into a `Set` is not possible, because the reconciler, Fizz, the Flight Server and DevTools read it by index and depend on its order. Fixes #37343 Closes #37358 Closes #37359 Co-authored-by: sundeep8967 <71071718+sundeep8967@users.noreply.github.com> --- .../react-client/src/ReactFlightClient.js | 44 +++++++++---- .../__tests__/ReactFlightDOMBrowser-test.js | 64 +++++++++++++++++++ .../ReactFlightAsyncDebugInfo-test.js | 55 ++++++++++++++++ 3 files changed, 152 insertions(+), 11 deletions(-) diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index 7ed683b110bd..1ffd5dcee7a0 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -197,6 +197,7 @@ type BlockedChunk = { _children: Array> | ProfilingResult, // Profiling-only _debugChunk: null, // DEV-only _debugInfo: ReactDebugInfo, // DEV-only + _receivedDebugInfo: null | Set, // DEV-only then(resolve: (T) => mixed, reject?: (mixed) => mixed): void, }; type ResolvedModelChunk = { @@ -276,6 +277,7 @@ function ReactPromise(status: any, value: any, reason: any) { if (__DEV__) { this._debugChunk = null; this._debugInfo = []; + this._receivedDebugInfo = null; } } // We subclass Promise.prototype so that we get other methods like .catch @@ -1170,6 +1172,10 @@ function initializeModelChunk(chunk: ResolvedModelChunk): void { return; } } + if (__DEV__) { + // Only a blocked chunk receives debug info, so release the set here. + cyclicChunk._receivedDebugInfo = null; + } const initializedChunk: InitializedChunk = chunk as any; initializedChunk.status = INITIALIZED; initializedChunk.value = value; @@ -1771,7 +1777,7 @@ function fulfillReference( const element: any = handler.value; switch (key) { case '3': - if (__DEV__) { + if (__DEV__ && !reference.isDebug) { transferReferencedDebugInfo(handler.chunk, fulfilledChunk); } element.props = mappedValue; @@ -1789,7 +1795,7 @@ function fulfillReference( } break; default: - if (__DEV__) { + if (__DEV__ && !reference.isDebug) { transferReferencedDebugInfo(handler.chunk, fulfilledChunk); } break; @@ -1810,6 +1816,10 @@ function fulfillReference( return; } const resolveListeners = chunk.value; + if (__DEV__) { + // Only a blocked chunk receives debug info, so release the set here. + chunk._receivedDebugInfo = null; + } const initializedChunk: InitializedChunk = chunk as any; initializedChunk.status = INITIALIZED; initializedChunk.value = handler.value; @@ -2148,25 +2158,37 @@ function resolveLazy(value: any): mixed { } function transferReferencedDebugInfo( - parentChunk: null | SomeChunk, + receivingChunk: null | BlockedChunk, referencedChunk: SomeChunk, ): void { if (__DEV__) { - // We add the debug info to the initializing chunk since the resolution of - // that promise is also blocked by the referenced debug info. By adding it - // to both we can track it even if the array/element/lazy is extracted, or - // if the root is rendered as is. - if (parentChunk !== null) { + // We add the debug info to the receiving chunk since the resolution of that + // promise is also blocked by the referenced debug info. By adding it to + // both we can track it even if the array/element/lazy is extracted, or if + // the root is rendered as is. + if (receivingChunk !== null) { const referencedDebugInfo = referencedChunk._debugInfo; - const parentDebugInfo = parentChunk._debugInfo; + const receivingDebugInfo = receivingChunk._debugInfo; + // The receiving chunk takes each entry only once. A repeated entry + // carries no information. An entry repeats in two ways: + // + // - the receiving chunk references the same chunk more than once + // - two referenced chunks carry the same entry + // + // Without the set, the entries multiply along a chain of references. + let receivedDebugInfo = receivingChunk._receivedDebugInfo; + if (receivedDebugInfo === null) { + receivedDebugInfo = receivingChunk._receivedDebugInfo = new Set(); + } for (let i = 0; i < referencedDebugInfo.length; ++i) { const debugInfoEntry = referencedDebugInfo[i]; if (debugInfoEntry.name != null) { debugInfoEntry as ReactComponentInfo; // We're not transferring Component info since we use Component info // in Debug info to fill in gaps between Fibers for the parent stack. - } else { - parentDebugInfo.push(debugInfoEntry); + } else if (!receivedDebugInfo.has(debugInfoEntry)) { + receivedDebugInfo.add(debugInfoEntry); + receivingDebugInfo.push(debugInfoEntry); } } } diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js index c8a7b8db9311..7c84c2a2e6c2 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js @@ -3441,6 +3441,70 @@ describe('ReactFlightDOMBrowser', () => { ); }); + it('should not exponentially accumulate debug info when deduplicated references are blocked', async () => { + // Regression test for debug info that grows exponentially, along the path + // that resolves a reference asynchronously. A form streams its field groups + // in parallel. Every group derives its descriptors from the group above it. + // Those descriptors deduplicate to the row of that group, which gives this + // row one reference per descriptor. Every descriptor names the client + // component of the field, and that chunk has not loaded, so every row + // blocks and the references wait for it. A row hands its debug info to the + // references that wait on it, so each of them copies the whole array and + // the count doubles at every group. + let loadFieldChunk; + const fieldChunkLoaded = new Promise(resolve => (loadFieldChunk = resolve)); + const Field = clientExports( + function Field() { + return null; + }, + '1', + '/field.js', + fieldChunkLoaded, + ); + + async function loadGroup(descriptors) { + return descriptors; + } + + const groupCount = 10; + const groups = []; + let descriptors = [{name: 'a'}, {name: 'b'}]; + for (let i = 0; i < groupCount; i++) { + descriptors = descriptors.map(descriptor => ({ + parent: descriptor, + Field, + })); + groups.push(loadGroup(descriptors)); + } + + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream({groups}, webpackMap), + ); + + const response = ReactServerDOMClient.createFromReadableStream(stream); + + // The root row holds only Promises, so it resolves while the field chunk is + // still loading. Subscribing to every group initializes its row, and a + // group finds the group above it blocked. + const form = await response; + const allGroups = Promise.all(form.groups); + loadFieldChunk(); + const resolvedGroups = await allGroups; + + expect(resolvedGroups).toHaveLength(groupCount); + + if (__DEV__) { + // A group resolves to an array, so Flight hands the debug info of the row + // to that array, which is what DevTools reads. Every group contributes a + // fixed number of entries, so the last group holds a multiple of the + // group count. Without deduplication in transferReferencedDebugInfo the + // count doubles at every group. + expect(resolvedGroups[groupCount - 1]._debugInfo.length).toBeLessThan( + 100, + ); + } + }); + describe('abort signal lifetime', () => { // Collects the lifetime signal that React bounds each abort listener with. // React passes that signal to addEventListener instead of calling diff --git a/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js b/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js index c11a2474e679..a019aa37b922 100644 --- a/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js +++ b/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js @@ -4160,4 +4160,59 @@ describe('ReactFlightAsyncDebugInfo', () => { `); } }); + + it('should not exponentially accumulate debug info on deduplicated model chunks', async () => { + // Regression test for debug info that grows exponentially with the length + // of the chain, along the path that resolves a reference synchronously. + // Each page derives its records from the records of the page before it. + // Those records deduplicate to the row of that page, which gives this row + // one reference per record. That page has already resolved by then, and it + // still holds its debug info because it resolves to a plain object. Only an + // array, an async iterable, an element, or a lazy node hands the debug info + // to the value. So each reference copies the entries of the previous page, + // and the count doubles at every page. + const pageCount = 10; + + async function loadPage(pageNumber, previousRecords) { + await delay(0); + const records = previousRecords.map(record => ({previous: record})); + return { + records, + nextPage: + pageNumber === pageCount ? null : loadPage(pageNumber + 1, records), + }; + } + + const stream = ReactServerDOMServer.renderToPipeableStream( + loadPage(1, [{id: 'a'}, {id: 'b'}]), + ); + + const readable = new Stream.PassThrough(streamOptions); + const result = ReactServerDOMClient.createFromNodeStream(readable, { + moduleMap: {}, + moduleLoading: {}, + }); + stream.pipe(readable); + + let page = await result; + let lastPage = null; + let pagesRead = 1; + while (page.nextPage !== null) { + lastPage = page.nextPage; + page = await page.nextPage; + pagesRead++; + } + expect(pagesRead).toBe(pageCount); + + await finishLoadingStream(readable); + + if (__DEV__) { + // Flight represents a Promise in the model with the chunk of its row, so + // this reads the debug info of the row itself. Every page contributes a + // fixed number of entries, so the last page holds a multiple of the page + // count. Without deduplication in transferReferencedDebugInfo the count + // doubles at every page. + expect(lastPage._debugInfo.length).toBeLessThan(100); + } + }); }); From 0d69e20fc03a7b2c750c3245d0ad19fb8e6f3b30 Mon Sep 17 00:00:00 2001 From: "Sebastian \"Sebbie\" Silbermann" Date: Wed, 2 Sep 2026 17:47:16 +0200 Subject: [PATCH 4/5] [flags] Enable conditional `use()` warning in React's Canary builds (#37491) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The warning was previously only enabled for experimental builds (`react@experimental`). This enables the warning for `react@canary` as well. Keep in mind that conditional `use()` is generally supported. This warning only triggers if the condition is based on `promise.status` (or `promise.value`). Let `use()` handle that status. React will not suspend if the `promise.status` is already `'fulfilled'`. More information can be found in the [`use()` docs under "Don’t skip calling use based on whether a Promise is already settled."](https://react.dev/reference/react/use#conditional-use). We've tested this at Vercel on the latest version of SWR (which previously had conditional `use()` calls) and found no false-positive warnings or excessive warnings. --- packages/shared/ReactFeatureFlags.js | 2 +- packages/shared/forks/ReactFeatureFlags.test-renderer.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/shared/ReactFeatureFlags.js b/packages/shared/ReactFeatureFlags.js index 94c7287f5260..7e566f23d52e 100644 --- a/packages/shared/ReactFeatureFlags.js +++ b/packages/shared/ReactFeatureFlags.js @@ -160,7 +160,7 @@ export const enableInfiniteRenderLoopDetection: boolean = false; */ export const enableInfiniteRenderLoopDetectionForceThrow: boolean = false; -export const enableConditionalUseWarning: boolean = __EXPERIMENTAL__; +export const enableConditionalUseWarning: boolean = true; export const enableFragmentRefs: boolean = true; export const enableFragmentRefsScrollIntoView: boolean = true; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.js index feac1b0e531e..d32bb6f95c8d 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.js @@ -55,7 +55,7 @@ export const disableClientCache: boolean = true; export const enableInfiniteRenderLoopDetection: boolean = false; export const enableInfiniteRenderLoopDetectionForceThrow: boolean = false; -export const enableConditionalUseWarning: boolean = __EXPERIMENTAL__; +export const enableConditionalUseWarning: boolean = true; export const enableEffectEventMutationPhase: boolean = true; From f4e439e1980f0748091f7d36c2005f0463197ed7 Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Wed, 2 Sep 2026 11:58:52 -0400 Subject: [PATCH 5/5] [Fizz] Add `nonce` to rendered `import maps` (#37339) Apply the render's script nonce to import maps emitted through the `importMap` server rendering option. This keeps configured import maps compatible with nonce-based Content Security Policies and uses the same escaped nonce value as other render-managed scripts. --- .../src/server/ReactFizzConfigDOM.js | 10 +++++- .../src/__tests__/ReactDOMFizzServer-test.js | 34 +++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js index 4e98c81c903a..6f24e538b1d0 100644 --- a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js +++ b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js @@ -457,7 +457,15 @@ export function createRenderState( const importMapChunks: Array = []; if (importMap !== undefined) { const map = importMap; - importMapChunks.push(importMapScriptStart); + importMapChunks.push( + nonceScript === undefined + ? importMapScriptStart + : stringToPrecomputedChunk( + '' + + (gate(flags => flags.shouldUseFizzExternalRuntime) + ? '' + : '') + + (gate(flags => flags.enableFizzBlockingRender) + ? '' + : ''), + ); + }); + // bugfix: https://github.com/facebook/react/issues/27286 it('can render custom elements with children on ther server', async () => { await act(() => {