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-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 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(() => { 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); + } + }); }); 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; 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() {