diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index ec69d8aea9870..c74ff15b01f00 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -155,6 +155,7 @@ const RESOLVED_MODEL = 'resolved_model'; const RESOLVED_MODULE = 'resolved_module'; const INITIALIZED = 'fulfilled'; const ERRORED = 'rejected'; +const HALTED = 'halted'; // DEV-only. Means it never resolves even if connection closes. type PendingChunk = { status: 'pending', @@ -221,13 +222,23 @@ type ErroredChunk = { _debugInfo?: null | ReactDebugInfo, // DEV-only then(resolve: (T) => mixed, reject?: (mixed) => mixed): void, }; +type HaltedChunk = { + status: 'halted', + value: null, + reason: null, + _response: Response, + _children: Array> | ProfilingResult, // Profiling-only + _debugInfo?: null | ReactDebugInfo, // DEV-only + then(resolve: (T) => mixed, reject?: (mixed) => mixed): void, +}; type SomeChunk = | PendingChunk | BlockedChunk | ResolvedModelChunk | ResolvedModuleChunk | InitializedChunk - | ErroredChunk; + | ErroredChunk + | HaltedChunk; // $FlowFixMe[missing-this-annot] function ReactPromise( @@ -311,6 +322,9 @@ ReactPromise.prototype.then = function ( chunk.reason.push(reject); } break; + case HALTED: { + break; + } default: if (reject) { reject(chunk.reason); @@ -368,6 +382,7 @@ function readChunk(chunk: SomeChunk): T { return chunk.value; case PENDING: case BLOCKED: + case HALTED: // eslint-disable-next-line no-throw-literal throw ((chunk: any): Thenable); default: @@ -1367,6 +1382,7 @@ function getOutlinedModel( return chunkValue; case PENDING: case BLOCKED: + case HALTED: return waitForReference(chunk, parentObject, key, response, map, path); default: // This is an error. Instead of erroring directly, we're going to encode this on @@ -1470,10 +1486,6 @@ function parseModelString( } case '@': { // Promise - if (value.length === 2) { - // Infinite promise that never resolves. - return new Promise(() => {}); - } const id = parseInt(value.slice(2), 16); const chunk = getChunk(response, id); if (enableProfilerTimer && enableComponentPerformanceTrack) { @@ -1769,6 +1781,22 @@ export function createResponse( ); } +function resolveDebugHalt(response: Response, id: number): void { + const chunks = response._chunks; + let chunk = chunks.get(id); + if (!chunk) { + chunks.set(id, (chunk = createPendingChunk(response))); + } else { + } + if (chunk.status !== PENDING && chunk.status !== BLOCKED) { + return; + } + const haltedChunk: HaltedChunk = (chunk: any); + haltedChunk.status = HALTED; + haltedChunk.value = null; + haltedChunk.reason = null; +} + function resolveModel( response: Response, id: number, @@ -3337,6 +3365,10 @@ function processFullStringRow( } // Fallthrough default: /* """ "{" "[" "t" "f" "n" "0" - "9" */ { + if (__DEV__ && row === '') { + resolveDebugHalt(response, id); + return; + } // We assume anything else is JSON. resolveModel(response, id, row); return; diff --git a/packages/react-client/src/__tests__/ReactFlight-test.js b/packages/react-client/src/__tests__/ReactFlight-test.js index c111acd428c8e..4c7aecc26955c 100644 --- a/packages/react-client/src/__tests__/ReactFlight-test.js +++ b/packages/react-client/src/__tests__/ReactFlight-test.js @@ -3213,7 +3213,8 @@ describe('ReactFlight', () => { prop: 123, fn: foo, map: new Map([['foo', foo]]), - promise: new Promise(() => {}), + promise: Promise.resolve('yo'), + infinitePromise: new Promise(() => {}), }); throw new Error('err'); } @@ -3258,9 +3259,14 @@ describe('ReactFlight', () => { }); ownerStacks = []; + // Let the Promises resolve. + await 0; + await 0; + await 0; + // The error should not actually get logged because we're not awaiting the root // so it's not thrown but the server log also shouldn't be replayed. - await ReactNoopFlightClient.read(transport); + await ReactNoopFlightClient.read(transport, {close: true}); expect(mockConsoleLog).toHaveBeenCalledTimes(1); expect(mockConsoleLog.mock.calls[0][0]).toBe('hi'); @@ -3280,6 +3286,23 @@ describe('ReactFlight', () => { const promise = mockConsoleLog.mock.calls[0][1].promise; expect(promise).toBeInstanceOf(Promise); + expect(await promise).toBe('yo'); + + const infinitePromise = mockConsoleLog.mock.calls[0][1].infinitePromise; + expect(infinitePromise).toBeInstanceOf(Promise); + let resolved = false; + infinitePromise.then( + () => (resolved = true), + x => { + console.error(x); + resolved = true; + }, + ); + await 0; + await 0; + await 0; + // This should not reject upon aborting the stream. + expect(resolved).toBe(false); expect(ownerStacks).toEqual(['\n in App (at **)']); }); diff --git a/packages/react-noop-renderer/src/ReactNoopFlightClient.js b/packages/react-noop-renderer/src/ReactNoopFlightClient.js index 3f71a13872f2b..c2be176b1a8b1 100644 --- a/packages/react-noop-renderer/src/ReactNoopFlightClient.js +++ b/packages/react-noop-renderer/src/ReactNoopFlightClient.js @@ -24,7 +24,7 @@ type Source = Array; const decoderOptions = {stream: true}; -const {createResponse, processBinaryChunk, getRoot} = ReactFlightClient({ +const {createResponse, processBinaryChunk, getRoot, close} = ReactFlightClient({ createStringDecoder() { return new TextDecoder(); }, @@ -56,6 +56,7 @@ const {createResponse, processBinaryChunk, getRoot} = ReactFlightClient({ type ReadOptions = {| findSourceMapURL?: FindSourceMapURLCallback, + close?: boolean, |}; function read(source: Source, options: ReadOptions): Thenable { @@ -74,6 +75,9 @@ function read(source: Source, options: ReadOptions): Thenable { for (let i = 0; i < source.length; i++) { processBinaryChunk(response, source[i], 0); } + if (options !== undefined && options.close) { + close(response); + } return getRoot(response); } diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index 4dccf0deb4591..4473dddee8d2a 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -677,6 +677,105 @@ export function resolveRequest(): null | Request { return null; } +function serializeDebugThenable( + request: Request, + counter: {objectLimit: number}, + thenable: Thenable, +): string { + // Like serializeThenable but for renderDebugModel + request.pendingChunks++; + const id = request.nextChunkId++; + const ref = serializePromiseID(id); + request.writtenDebugObjects.set(thenable, ref); + + switch (thenable.status) { + case 'fulfilled': { + emitOutlinedDebugModelChunk(request, id, counter, thenable.value); + return ref; + } + case 'rejected': { + const x = thenable.reason; + if ( + enablePostpone && + typeof x === 'object' && + x !== null && + (x: any).$$typeof === REACT_POSTPONE_TYPE + ) { + const postponeInstance: Postpone = (x: any); + // We don't log this postpone. + emitPostponeChunk(request, id, postponeInstance); + } else { + // We don't log these errors since they didn't actually throw into Flight. + const digest = ''; + emitErrorChunk(request, id, digest, x); + } + return ref; + } + } + + let cancelled = false; + + thenable.then( + value => { + if (cancelled) { + return; + } + cancelled = true; + if (request.status === ABORTING) { + emitDebugHaltChunk(request, id); + enqueueFlush(request); + return; + } + emitOutlinedDebugModelChunk(request, id, counter, value); + enqueueFlush(request); + }, + reason => { + if (cancelled) { + return; + } + cancelled = true; + if (request.status === ABORTING) { + emitDebugHaltChunk(request, id); + enqueueFlush(request); + return; + } + if ( + enablePostpone && + typeof reason === 'object' && + reason !== null && + (reason: any).$$typeof === REACT_POSTPONE_TYPE + ) { + const postponeInstance: Postpone = (reason: any); + // We don't log this postpone. + emitPostponeChunk(request, id, postponeInstance); + } else { + // We don't log these errors since they didn't actually throw into Flight. + const digest = ''; + emitErrorChunk(request, id, digest, reason); + } + enqueueFlush(request); + }, + ); + + // We don't use scheduleMicrotask here because it doesn't actually schedule a microtask + // in all our configs which is annoying. + Promise.resolve().then(() => { + // If we don't resolve the Promise within a microtask. Leave it as hanging since we + // don't want to block the render forever on a Promise that might never resolve. + if (cancelled) { + return; + } + cancelled = true; + emitDebugHaltChunk(request, id); + enqueueFlush(request); + // Clean up the request so we don't leak this forever. + request = (null: any); + counter = (null: any); + }); + + return ref; +} + function serializeThenable( request: Request, task: Task, @@ -2200,10 +2299,6 @@ function serializeLazyID(id: number): string { return '$L' + id.toString(16); } -function serializeInfinitePromise(): string { - return '$@'; -} - function serializePromiseID(id: number): string { return '$@' + id.toString(16); } @@ -3520,6 +3615,21 @@ function emitModelChunk(request: Request, id: number, json: string): void { request.completedRegularChunks.push(processedChunk); } +function emitDebugHaltChunk(request: Request, id: number): void { + if (!__DEV__) { + // These errors should never make it into a build so we don't need to encode them in codes.json + // eslint-disable-next-line react-internal/prod-error-codes + throw new Error( + 'emitDebugHaltChunk should never be called in production mode. This is a bug in React.', + ); + } + // This emits a marker that this row will never complete and should intentionally never resolve + // even when the client stream is closed. We use just the lack of data to indicate this. + const row = id.toString(16) + ':\n'; + const processedChunk = stringToChunk(row); + request.completedRegularChunks.push(processedChunk); +} + function emitDebugChunk( request: Request, id: number, @@ -3956,36 +4066,7 @@ function renderDebugModel( // $FlowFixMe[method-unbinding] if (typeof value.then === 'function') { const thenable: Thenable = (value: any); - switch (thenable.status) { - case 'fulfilled': { - return serializePromiseID( - outlineDebugModel(request, counter, thenable.value), - ); - } - case 'rejected': { - const x = thenable.reason; - request.pendingChunks++; - const errorId = request.nextChunkId++; - if ( - enablePostpone && - typeof x === 'object' && - x !== null && - (x: any).$$typeof === REACT_POSTPONE_TYPE - ) { - const postponeInstance: Postpone = (x: any); - // We don't log this postpone. - emitPostponeChunk(request, errorId, postponeInstance); - } else { - // We don't log these errors since they didn't actually throw into Flight. - const digest = ''; - emitErrorChunk(request, errorId, digest, x); - } - return serializePromiseID(errorId); - } - } - // If it hasn't already resolved (and been instrumented) we just encode an infinite - // promise that will never resolve. - return serializeInfinitePromise(); + return serializeDebugThenable(request, counter, thenable); } if (isArray(value)) { @@ -4212,16 +4293,17 @@ function serializeDebugModel( } } -function outlineDebugModel( +function emitOutlinedDebugModelChunk( request: Request, + id: number, counter: {objectLimit: number}, model: ReactClientValue, -): number { +): void { if (!__DEV__) { // These errors should never make it into a build so we don't need to encode them in codes.json // eslint-disable-next-line react-internal/prod-error-codes throw new Error( - 'outlineDebugModel should never be called in production mode. This is a bug in React.', + 'emitOutlinedDebugModel should never be called in production mode. This is a bug in React.', ); } @@ -4252,7 +4334,6 @@ function outlineDebugModel( } } - const id = request.nextChunkId++; const prevModelRoot = debugModelRoot; debugModelRoot = model; if (typeof model === 'object' && model !== null) { @@ -4272,10 +4353,27 @@ function outlineDebugModel( debugModelRoot = prevModelRoot; } - request.pendingChunks++; const row = id.toString(16) + ':' + json + '\n'; const processedChunk = stringToChunk(row); request.completedRegularChunks.push(processedChunk); +} + +function outlineDebugModel( + request: Request, + counter: {objectLimit: number}, + model: ReactClientValue, +): number { + if (!__DEV__) { + // These errors should never make it into a build so we don't need to encode them in codes.json + // eslint-disable-next-line react-internal/prod-error-codes + throw new Error( + 'outlineDebugModel should never be called in production mode. This is a bug in React.', + ); + } + + const id = request.nextChunkId++; + request.pendingChunks++; + emitOutlinedDebugModelChunk(request, id, counter, model); return id; }