diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js index 7d8707bcd3f22..5a763ffe949ab 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js @@ -7746,6 +7746,112 @@ describe('ReactDOMFizzServer', () => { ); }); + // @gate enableHalt + it('can resume a prerender that was aborted', async () => { + const promise = new Promise(r => {}); + + let prerendering = true; + + function Wait() { + if (prerendering) { + return React.use(promise); + } else { + return 'Hello'; + } + } + + function App() { + return ( +
+ +

+ + + + + +

+

+ + + + + +

+
+
+ ); + } + + const controller = new AbortController(); + const signal = controller.signal; + + const errors = []; + function onError(error) { + errors.push(error); + } + let pendingPrerender; + await act(() => { + pendingPrerender = ReactDOMFizzStatic.prerenderToNodeStream(, { + signal, + onError, + }); + }); + controller.abort('boom'); + + const prerendered = await pendingPrerender; + + expect(errors).toEqual(['boom', 'boom']); + + const preludeWritable = new Stream.PassThrough(); + preludeWritable.setEncoding('utf8'); + preludeWritable.on('data', chunk => { + writable.write(chunk); + }); + + await act(() => { + prerendered.prelude.pipe(preludeWritable); + }); + + expect(getVisibleChildren(container)).toEqual( +
+

+ Loading again... +

+

+ Loading again too... +

+
, + ); + + prerendering = false; + + errors.length = 0; + const resumed = await ReactDOMFizzServer.resumeToPipeableStream( + , + JSON.parse(JSON.stringify(prerendered.postponed)), + { + onError, + }, + ); + + await act(() => { + resumed.pipe(writable); + }); + + expect(errors).toEqual([]); + expect(getVisibleChildren(container)).toEqual( +
+

+ Hello +

+

+ Hello +

+
, + ); + }); + // @gate enablePostpone it('does not call onError when you abort with a postpone instance during resume', async () => { let prerendering = true; diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzStatic-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzStatic-test.js index 1f3bfa7b3308d..f31970e339bda 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzStatic-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzStatic-test.js @@ -454,4 +454,56 @@ describe('ReactDOMFizzStatic', () => { }); expect(getVisibleChildren(container)).toEqual(undefined); }); + + // @enableHalt + it('will halt a prerender when aborting with an error during a render', async () => { + const controller = new AbortController(); + function App() { + controller.abort('sync'); + return
hello world
; + } + + const errors = []; + const result = await ReactDOMFizzStatic.prerenderToNodeStream(, { + signal: controller.signal, + onError(error) { + errors.push(error); + }, + }); + await act(async () => { + result.prelude.pipe(writable); + }); + expect(errors).toEqual(['sync']); + expect(getVisibleChildren(container)).toEqual(undefined); + }); + + // @enableHalt + it('will halt a prerender when aborting with an error in a microtask', async () => { + const errors = []; + + const controller = new AbortController(); + function App() { + React.use( + new Promise(() => { + Promise.resolve().then(() => { + controller.abort('async'); + }); + }), + ); + return
hello world
; + } + + errors.length = 0; + const result = await ReactDOMFizzStatic.prerenderToNodeStream(, { + signal: controller.signal, + onError(error) { + errors.push(error); + }, + }); + await act(async () => { + result.prelude.pipe(writable); + }); + expect(errors).toEqual(['async']); + expect(getVisibleChildren(container)).toEqual(undefined); + }); }); diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js index 7a3db48b016e3..5a73507f87a71 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js @@ -306,8 +306,8 @@ describe('ReactDOMFizzStaticBrowser', () => { expect(errors).toEqual(['The operation was aborted.']); }); - // @gate experimental - it('should reject if aborting before the shell is complete', async () => { + // @gate !enableHalt + it('should reject if aborting before the shell is complete and enableHalt is disabled', async () => { const errors = []; const controller = new AbortController(); const promise = serverAct(() => @@ -339,6 +339,42 @@ describe('ReactDOMFizzStaticBrowser', () => { expect(errors).toEqual(['aborted for reasons']); }); + // @gate enableHalt + it('should resolve an empty prelude if aborting before the shell is complete', async () => { + const errors = []; + const controller = new AbortController(); + const promise = serverAct(() => + ReactDOMFizzStatic.prerender( +
+ +
, + { + signal: controller.signal, + onError(x) { + errors.push(x.message); + }, + }, + ), + ); + + await jest.runAllTimers(); + + const theReason = new Error('aborted for reasons'); + controller.abort(theReason); + + let rejected = false; + let prelude; + try { + ({prelude} = await promise); + } catch (error) { + rejected = true; + } + expect(rejected).toBe(false); + expect(errors).toEqual(['aborted for reasons']); + const content = await readContent(prelude); + expect(content).toBe(''); + }); + // @gate experimental it('should be able to abort before something suspends', async () => { const errors = []; @@ -375,8 +411,8 @@ describe('ReactDOMFizzStaticBrowser', () => { expect(errors).toEqual(['The operation was aborted.']); }); - // @gate experimental - it('should reject if passing an already aborted signal', async () => { + // @gate !enableHalt + it('should reject if passing an already aborted signal and enableHalt is disabled', async () => { const errors = []; const controller = new AbortController(); const theReason = new Error('aborted for reasons'); @@ -410,6 +446,44 @@ describe('ReactDOMFizzStaticBrowser', () => { expect(errors).toEqual(['aborted for reasons']); }); + // @gate enableHalt + it('should resolve an empty prelude if passing an already aborted signal', async () => { + const errors = []; + const controller = new AbortController(); + const theReason = new Error('aborted for reasons'); + controller.abort(theReason); + + const promise = serverAct(() => + ReactDOMFizzStatic.prerender( +
+ Loading
}> + + + , + { + signal: controller.signal, + onError(x) { + errors.push(x.message); + }, + }, + ), + ); + + // Technically we could still continue rendering the shell but currently the + // semantics mean that we also abort any pending CPU work. + let didThrow = false; + let prelude; + try { + ({prelude} = await promise); + } catch (error) { + didThrow = true; + } + expect(didThrow).toBe(false); + expect(errors).toEqual(['aborted for reasons']); + const content = await readContent(prelude); + expect(content).toBe(''); + }); + // @gate experimental it('supports custom abort reasons with a string', async () => { const promise = new Promise(r => {}); diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzStaticNode-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzStaticNode-test.js index ade755bdffea1..2a7b4e0cfa4d2 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzStaticNode-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzStaticNode-test.js @@ -211,8 +211,8 @@ describe('ReactDOMFizzStaticNode', () => { expect(errors).toEqual(['This operation was aborted']); }); - // @gate experimental - it('should reject if aborting before the shell is complete', async () => { + // @gate !enableHalt + it('should reject if aborting before the shell is complete and enableHalt is disabled', async () => { const errors = []; const controller = new AbortController(); const promise = ReactDOMFizzStatic.prerenderToNodeStream( @@ -242,6 +242,40 @@ describe('ReactDOMFizzStaticNode', () => { expect(errors).toEqual(['aborted for reasons']); }); + // @gate enableHalt + it('should resolve an empty shell if aborting before the shell is complete', async () => { + const errors = []; + const controller = new AbortController(); + const promise = ReactDOMFizzStatic.prerenderToNodeStream( +
+ +
, + { + signal: controller.signal, + onError(x) { + errors.push(x.message); + }, + }, + ); + + await jest.runAllTimers(); + + const theReason = new Error('aborted for reasons'); + controller.abort(theReason); + + let didThrow = false; + let prelude; + try { + ({prelude} = await promise); + } catch (error) { + didThrow = true; + } + expect(didThrow).toBe(false); + expect(errors).toEqual(['aborted for reasons']); + const content = await readContent(prelude); + expect(content).toBe(''); + }); + // @gate experimental it('should be able to abort before something suspends', async () => { const errors = []; @@ -276,8 +310,8 @@ describe('ReactDOMFizzStaticNode', () => { expect(errors).toEqual(['This operation was aborted']); }); - // @gate experimental - it('should reject if passing an already aborted signal', async () => { + // @gate !enableHalt + it('should reject if passing an already aborted signal and enableHalt is disabled', async () => { const errors = []; const controller = new AbortController(); const theReason = new Error('aborted for reasons'); @@ -309,6 +343,43 @@ describe('ReactDOMFizzStaticNode', () => { expect(errors).toEqual(['aborted for reasons']); }); + // @gate enableHalt + it('should resolve with an empty prelude if passing an already aborted signal', async () => { + const errors = []; + const controller = new AbortController(); + const theReason = new Error('aborted for reasons'); + controller.abort(theReason); + + const promise = ReactDOMFizzStatic.prerenderToNodeStream( +
+ Loading
}> + + + , + { + signal: controller.signal, + onError(x) { + errors.push(x.message); + }, + }, + ); + + // Technically we could still continue rendering the shell but currently the + // semantics mean that we also abort any pending CPU work. + + let didThrow = false; + let prelude; + try { + ({prelude} = await promise); + } catch (error) { + didThrow = true; + } + expect(didThrow).toBe(false); + expect(errors).toEqual(['aborted for reasons']); + const content = await readContent(prelude); + expect(content).toBe(''); + }); + // @gate experimental it('supports custom abort reasons with a string', async () => { const promise = new Promise(r => {}); diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index 17c278f2b0b52..daea492db5b8e 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -157,6 +157,7 @@ import { enableSuspenseAvoidThisFallbackFizz, enableCache, enablePostpone, + enableHalt, enableRenderableContext, enableRefAsProp, disableDefaultPropsExceptForClasses, @@ -3625,6 +3626,9 @@ function erroredTask( ) { // Report the error to a global handler. let errorDigest; + // We don't handle halts here because we only halt when prerendering and + // when prerendering we should be finishing tasks not erroring them when + // they halt or postpone if ( enablePostpone && typeof error === 'object' && @@ -3812,6 +3816,17 @@ function abortTask(task: Task, request: Request, error: mixed): void { logRecoverableError(request, fatal, errorInfo, null); fatalError(request, fatal, errorInfo, null); } + } else if ( + enableHalt && + request.trackedPostpones !== null && + segment !== null + ) { + const trackedPostpones = request.trackedPostpones; + // We are aborting a prerender and must treat the shell as halted + // We log the error but we still resolve the prerender + logRecoverableError(request, error, errorInfo, null); + trackPostpone(request, trackedPostpones, task, segment); + finishedTask(request, null, segment); } else { logRecoverableError(request, error, errorInfo, null); fatalError(request, error, errorInfo, null); @@ -3856,10 +3871,40 @@ function abortTask(task: Task, request: Request, error: mixed): void { } } else { boundary.pendingTasks--; + // We construct an errorInfo from the boundary's componentStack so the error in dev will indicate which + // boundary the message is referring to + const errorInfo = getThrownInfo(task.componentStack); + const trackedPostpones = request.trackedPostpones; if (boundary.status !== CLIENT_RENDERED) { - // We construct an errorInfo from the boundary's componentStack so the error in dev will indicate which - // boundary the message is referring to - const errorInfo = getThrownInfo(task.componentStack); + if (enableHalt) { + if (trackedPostpones !== null && segment !== null) { + // We are aborting a prerender + if ( + enablePostpone && + typeof error === 'object' && + error !== null && + error.$$typeof === REACT_POSTPONE_TYPE + ) { + const postponeInstance: Postpone = (error: any); + logPostpone(request, postponeInstance.message, errorInfo, null); + } else { + // We are aborting a prerender and must halt this boundary. + // We treat this like other postpones during prerendering + logRecoverableError(request, error, errorInfo, null); + } + trackPostpone(request, trackedPostpones, task, segment); + // If this boundary was still pending then we haven't already cancelled its fallbacks. + // We'll need to abort the fallbacks, which will also error that parent boundary. + boundary.fallbackAbortableTasks.forEach(fallbackTask => + abortTask(fallbackTask, request, error), + ); + boundary.fallbackAbortableTasks.clear(); + return finishedTask(request, boundary, segment); + } + } + boundary.status = CLIENT_RENDERED; + // We are aborting a render or resume which should put boundaries + // into an explicitly client rendered state let errorDigest; if ( enablePostpone && @@ -4145,6 +4190,43 @@ function retryRenderTask( ? request.fatalError : thrownValue; + if ( + enableHalt && + request.status === ABORTING && + request.trackedPostpones !== null + ) { + // We are aborting a prerender and need to halt this task. + const trackedPostpones = request.trackedPostpones; + const thrownInfo = getThrownInfo(task.componentStack); + task.abortSet.delete(task); + + if ( + enablePostpone && + typeof x === 'object' && + x !== null && + x.$$typeof === REACT_POSTPONE_TYPE + ) { + const postponeInstance: Postpone = (x: any); + logPostpone( + request, + postponeInstance.message, + thrownInfo, + __DEV__ && enableOwnerStacks ? task.debugTask : null, + ); + } else { + logRecoverableError( + request, + x, + thrownInfo, + __DEV__ && enableOwnerStacks ? task.debugTask : null, + ); + } + + trackPostpone(request, trackedPostpones, task, segment); + finishedTask(request, task.blockedBoundary, segment); + return; + } + if (typeof x === 'object' && x !== null) { // $FlowFixMe[method-unbinding] if (typeof x.then === 'function') {