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') {