From 3e7e2d77009fb5e2e7aad620681f22c27447e467 Mon Sep 17 00:00:00 2001 From: Dan Abramov Date: Mon, 27 Jul 2026 03:18:17 +0100 Subject: [PATCH] [Flight] Failing test: async debug info history never expires A tracking node lives as long as its Promise, but a still-reachable Promise pins its entire ancestry of nodes, stacks and owners through the awaited/previous links. A long-lived loop that keeps awaiting outside of any request (a dev server between requests, an event stream) accumulates a couple of KB of history per iteration forever (vercel/next.js#85666). Retention is observable through a request backdated with options.startTime: it receives history from before its creation for as long as the tracking retains it. The test fetches data outside any request, churns through enough newer operations to age the fetch out, and renders a request backdated to before the fetch. On this commit the fetch's full history is still emitted -- nothing ever expires, which is the leak. The next commit bounds retention and makes it pass. --- ...eactFlightAsyncDebugInfoExpiration-test.js | 163 ++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 packages/react-server/src/__tests__/ReactFlightAsyncDebugInfoExpiration-test.js 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([]); + } + }); +});