diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index 7ac416bbe6b6..f2254d471cda 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -361,7 +361,7 @@ type Response = { _debugRootStack?: null | Error, // DEV-only _debugRootTask?: null | ConsoleTask, // DEV-only _debugStartTime: number, // DEV-only - _debugEndTime?: number, // DEV-only + _debugEndTime: null | number, // DEV-only _debugIOStarted: boolean, // DEV-only _debugFindSourceMapURL?: void | FindSourceMapURLCallback, // DEV-only _debugChannel?: void | DebugChannel, // DEV-only @@ -499,7 +499,6 @@ function filterDebugInfo( response: Response, value: {_debugInfo: ReactDebugInfo, ...}, ) { - // $FlowFixMe[invalid-compare] if (response._debugEndTime === null) { // No end time was defined, so we keep all debug info entries. return; @@ -523,6 +522,29 @@ function filterDebugInfo( value._debugInfo = debugInfo; } +function pruneDebugInfoAfterError( + response: Response, + chunk: ErroredChunk, +): void { + if (response._debugEndTime === null) { + return; + } + + const relativeEndTime = + response._debugEndTime - + // $FlowFixMe[prop-missing] + performance.timeOrigin; + const debugInfo = chunk._debugInfo; + for (let i = 0; i < debugInfo.length; i++) { + const info = debugInfo[i]; + if (typeof info.time === 'number' && info.time > relativeEndTime) { + // This array may already be attached to the Lazy suspended in Fizz. + debugInfo.length = i; + return; + } + } +} + function moveDebugInfoFromChunkToInnerValue( chunk: InitializedChunk | InitializedStreamChunk, value: T, @@ -764,6 +786,9 @@ function triggerErrorOnChunk( const erroredChunk: ErroredChunk = chunk as any; erroredChunk.status = ERRORED; erroredChunk.reason = error; + if (__DEV__) { + pruneDebugInfoAfterError(response, erroredChunk); + } if (listeners !== null) { rejectChunk(response, listeners, error); } @@ -2762,7 +2787,7 @@ function ResponseInstance( // and is not considered I/O required to load the stream. setTimeout(markIOStarted.bind(this), 0); } - this._debugEndTime = debugEndTime == null ? null : debugEndTime; + this._debugEndTime = debugEndTime === undefined ? null : debugEndTime; this._debugFindSourceMapURL = findSourceMapURL; this._debugChannel = debugChannel; this._blockedConsole = null; diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js index 27853fc23810..adda7f51ca37 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js @@ -2055,6 +2055,155 @@ describe('ReactFlightDOMNode', () => { 'ssr-abort', ); }); + + // @gate __DEV__ + it('filters parsed debug info when the Flight stream errors', async () => { + let resolveInitialData; + const laterDataResolvers = []; + + async function getInitialData() { + return new Promise(resolve => { + resolveInitialData = resolve; + }); + } + + async function loadInitialData() { + return await getInitialData(); + } + + async function loadLaterData() { + for (let i = 0; i < 40; i++) { + await new Promise(resolve => { + laterDataResolvers[i] = resolve; + }); + } + } + + async function Dynamic() { + await loadInitialData(); + await loadLaterData(); + return ReactServer.createElement('p', null, 'Done'); + } + + function App() { + return ReactServer.createElement( + 'html', + null, + ReactServer.createElement( + 'body', + null, + ReactServer.createElement(Dynamic), + ), + ); + } + + let staticEndTime = -1; + const chunks = []; + + await new Promise(resolve => { + setTimeout(() => { + const flightStream = ReactServerDOMServer.renderToPipeableStream( + ReactServer.createElement(App), + webpackMap, + { + filterStackFrame, + }, + ); + + const passThrough = new Stream.PassThrough(streamOptions); + flightStream.pipe(passThrough); + passThrough.on('data', chunk => { + chunks.push(chunk); + }); + passThrough.on('end', resolve); + }); + + setTimeout(() => { + staticEndTime = performance.now() + performance.timeOrigin; + resolveInitialData(); + + let index = 0; + function resolveNext() { + setTimeout(() => { + laterDataResolvers[index++](); + if (index < 40) { + resolveNext(); + } + }); + } + setTimeout(resolveNext); + }); + }); + + const contentStream = new Stream.Readable({ + ...streamOptions, + read() {}, + }); + const response = ReactServerDOMClient.createFromNodeStream( + contentStream, + { + moduleMap: null, + moduleLoading: null, + serverModuleMap: null, + }, + { + endTime: staticEndTime, + }, + ); + // The final write contains the completed model. The preceding writes + // contain the debug rows produced while rendering it. + for (let i = 0; i < chunks.length - 1; i++) { + contentStream.push(chunks[i]); + } + + const decoded = await response; + + function ClientRoot() { + return decoded; + } + + const flightError = new Error('Flight stream errored'); + const fizzAbortController = new AbortController(); + let caughtError; + let ownerStack; + const {prelude} = await new Promise(resolve => { + let result; + + setTimeout(() => { + result = ReactDOMFizzStatic.prerenderToNodeStream( + React.createElement(ClientRoot), + { + signal: fizzAbortController.signal, + onError(error) { + caughtError = error; + ownerStack = React.captureOwnerStack + ? React.captureOwnerStack() + : null; + }, + }, + ); + }); + + setTimeout(() => { + contentStream.emit('error', flightError); + contentStream.push(null); + fizzAbortController.abort(new Error('Fizz aborted')); + resolve(result); + }); + }); + + expect(await readResult(prelude)).toBe(''); + expect(caughtError).toBe(flightError); + expect(normalizeCodeLocInfo(ownerStack)).toBe( + '\n' + + gate(flags => + flags.enableAsyncDebugInfo + ? ' in loadInitialData (at **)\n' + ' in Dynamic (at **)\n' + : '', + ) + + ' in App (at **)', + ); + }); }); it('warns with a tailored message if eval is not available in dev', async () => {