diff --git a/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfoExpiration-test.js b/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfoExpiration-test.js new file mode 100644 index 0000000000..661b2c9989 --- /dev/null +++ b/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfoExpiration-test.js @@ -0,0 +1,163 @@ +/** + * @jest-environment node + */ +'use strict'; + +// The async debug info tracking retains the history behind every tracked +// operation only for as long as some request could still emit it. History +// expires after enough newer operations have been tracked, so these tests +// churn through awaits to age it. + +import {patchSetImmediate} from '../../../../scripts/jest/patchSetImmediate'; + +let React; +let ReactServerDOMServer; +let ReactServerDOMClient; +let Stream; +let getDebugInfo; + +const streamOptions = { + objectMode: true, +}; + +function filterStackFrame(filename, functionName) { + return ( + !filename.startsWith('node:') && + !filename.includes('node_modules') && + // Filter out our own internal source code since it'll typically be in node_modules + (!filename.includes('/packages/') || filename.includes('/__tests__/')) && + !filename.includes('/build/') && + !functionName.includes('internal_') + ); +} + +describe('ReactFlightAsyncDebugInfoExpiration', () => { + beforeEach(() => { + jest.resetModules(); + jest.useRealTimers(); + patchSetImmediate(); + global.console = require('console'); + + jest.mock('react', () => require('react/react.react-server')); + jest.mock('react-server-dom-webpack/server', () => + jest.requireActual('react-server-dom-webpack/server.node'), + ); + require('react'); + ReactServerDOMServer = require('react-server-dom-webpack/server'); + + jest.resetModules(); + jest.useRealTimers(); + patchSetImmediate(); + + __unmockReact(); + jest.unmock('react-server-dom-webpack/server'); + jest.mock('react-server-dom-webpack/client', () => + jest.requireActual('react-server-dom-webpack/client.node'), + ); + + React = require('react'); + ReactServerDOMClient = require('react-server-dom-webpack/client'); + Stream = require('stream'); + + getDebugInfo = require('internal-test-utils').getDebugInfo.bind(null, { + ignoreProps: true, + useFixedTime: true, + }); + }); + + function delay(timeout) { + return new Promise(resolve => { + setTimeout(resolve, timeout); + }); + } + + function finishLoadingStream(readable) { + return new Promise(resolve => { + if (readable.readableEnded) { + resolve(); + } else { + readable.on('end', () => resolve()); + } + }); + } + + // Enough tracked operations to age everything created before them past the + // retention budget. Every Promise is tracked at creation and the expiration + // sweeps run every 128 tracked operations, so nothing needs to be awaited. + function churnPastRetention() { + for (let i = 0; i < 20; i++) { + for (let j = 0; j < 1500; j++) { + Promise.resolve(j); + } + } + } + + it('expires history that resolved long before a backdated request', async () => { + // A request initializes the tracking; after the stream closes, nothing + // pins history anymore. + async function Init() { + await delay(1); + return 'ok'; + } + const initStream = ReactServerDOMServer.renderToPipeableStream( + , + {}, + {filterStackFrame}, + ); + const initReadable = new Stream.PassThrough(streamOptions); + const initResult = ReactServerDOMClient.createFromNodeStream(initReadable, { + moduleMap: {}, + moduleLoading: {}, + }); + initStream.pipe(initReadable); + expect(await initResult).toBe('ok'); + await finishLoadingStream(initReadable); + + // Data fetched outside any request and kept alive, like a module-level + // cache. A request backdated to before the fetch may still consume its + // history for as long as the retention window allows. + async function fetchCachedData() { + await delay(5); + return 'hello'; + } + const cachedStartTime = + // $FlowFixMe[prop-missing] + performance.timeOrigin + performance.now(); + const cachedData = fetchCachedData(); + await cachedData; + + churnPastRetention(); + + // This request claims it started before the fetch, but arrived long + // after it, so the fetch's history must be gone. + async function Component() { + return 'data:' + (await cachedData); + } + const stream = ReactServerDOMServer.renderToPipeableStream( + , + {}, + { + filterStackFrame, + startTime: cachedStartTime, + }, + ); + const readable = new Stream.PassThrough(streamOptions); + const result = ReactServerDOMClient.createFromNodeStream(readable, { + moduleMap: {}, + moduleLoading: {}, + }); + stream.pipe(readable); + expect(await result).toBe('data:hello'); + await finishLoadingStream(readable); + + if ( + __DEV__ && + gate( + flags => + flags.enableComponentPerformanceTrack && flags.enableAsyncDebugInfo, + ) + ) { + expect(getDebugInfo(result).filter(entry => entry.awaited)).toEqual([]); + } + }); +});