+ );
+ }
+
+ const abortRef = {current: null};
+ const lazyAbort = React.lazy(() => {
+ abortRef.current();
+ return {
+ then(cb) {
+ cb({default: 'hello world'});
+ },
+ };
+ });
+
+ const {writable: flightWritable, readable: flightReadable} =
+ getTestStream();
+
+ await serverAct(() => {
+ const {pipe, abort} = ReactServerDOMServer.renderToPipeableStream(
+ ,
+ webpackMap,
+ );
+ abortRef.current = abort;
+ pipe(flightWritable);
+ });
+ assertConsoleErrorDev([
+ 'The render was aborted by the server without a reason.',
+ ]);
+
+ const response =
+ ReactServerDOMClient.createFromReadableStream(flightReadable);
+
+ const {writable: fizzWritable, readable: fizzReadable} = getTestStream();
+
+ function ClientApp() {
+ return use(response);
+ }
+
+ const shellErrors = [];
+ await serverAct(async () => {
+ ReactDOMFizzServer.renderToPipeableStream(
+ React.createElement(ClientApp),
+ {
+ onShellError(error) {
+ shellErrors.push(error.message);
+ },
+ },
+ ).pipe(fizzWritable);
+ });
+ assertConsoleErrorDev([
+ 'The render was aborted by the server without a reason.',
+ 'The render was aborted by the server without a reason.',
+ 'The render was aborted by the server without a reason.',
+ ]);
+
+ expect(shellErrors).toEqual([]);
+
+ const container = document.createElement('div');
+ await readInto(container, fizzReadable);
+ expect(getMeaningfulChildren(container)).toEqual(
+
+
loading 1...
+
loading 2...
+
+
loading 3...
+
+
,
+ );
+ });
+
+ it('can abort during a synchronous thenable resolution', async () => {
+ function Sibling() {
+ return
sibling
;
+ }
+
+ function App() {
+ return (
+
+ loading 1...}>{thenable}
+ loading 2...}>
+
+
+
+ loading 3...}>
+
+
+
+
+
+
+ );
+ }
+
+ const abortRef = {current: null};
+ const thenable = {
+ then(cb) {
+ abortRef.current();
+ cb(thenable.value);
+ },
+ };
+
+ const {writable: flightWritable, readable: flightReadable} =
+ getTestStream();
+
+ await serverAct(() => {
+ const {pipe, abort} = ReactServerDOMServer.renderToPipeableStream(
+ ,
+ webpackMap,
+ );
+ abortRef.current = abort;
+ pipe(flightWritable);
+ });
+
+ assertConsoleErrorDev([
+ 'The render was aborted by the server without a reason.',
+ ]);
+
+ const response =
+ ReactServerDOMClient.createFromReadableStream(flightReadable);
+
+ const {writable: fizzWritable, readable: fizzReadable} = getTestStream();
+
+ function ClientApp() {
+ return use(response);
+ }
+
+ const shellErrors = [];
+ await serverAct(async () => {
+ ReactDOMFizzServer.renderToPipeableStream(
+ React.createElement(ClientApp),
+ {
+ onShellError(error) {
+ shellErrors.push(error.message);
+ },
+ },
+ ).pipe(fizzWritable);
+ });
+ assertConsoleErrorDev([
+ 'The render was aborted by the server without a reason.',
+ 'The render was aborted by the server without a reason.',
+ 'The render was aborted by the server without a reason.',
+ ]);
+
+ expect(shellErrors).toEqual([]);
+
+ const container = document.createElement('div');
+ await readInto(container, fizzReadable);
+ expect(getMeaningfulChildren(container)).toEqual(
+
+
loading 1...
+
loading 2...
+
+
loading 3...
+
+
,
+ );
+ });
+
+ it('wont serialize thenables that were not already settled by the time an abort happens', async () => {
+ function App() {
+ return (
+
+ );
+ }
+
+ const abortRef = {current: null};
+ const thenable1 = {
+ then(cb) {
+ cb('hello world');
+ },
+ };
+
+ const thenable2 = {
+ then(cb) {
+ cb('hello world');
+ },
+ status: 'fulfilled',
+ value: 'hello world',
+ };
+
+ function ComponentThatAborts() {
+ abortRef.current();
+ return thenable1;
+ }
+
+ const {writable: flightWritable, readable: flightReadable} =
+ getTestStream();
+
+ await serverAct(() => {
+ const {pipe, abort} = ReactServerDOMServer.renderToPipeableStream(
+ ,
+ webpackMap,
+ );
+ abortRef.current = abort;
+ pipe(flightWritable);
+ });
+
+ assertConsoleErrorDev([
+ 'The render was aborted by the server without a reason.',
+ ]);
+
+ const response =
+ ReactServerDOMClient.createFromReadableStream(flightReadable);
+
+ const {writable: fizzWritable, readable: fizzReadable} = getTestStream();
+
+ function ClientApp() {
+ return use(response);
+ }
+
+ const shellErrors = [];
+ await serverAct(async () => {
+ ReactDOMFizzServer.renderToPipeableStream(
+ React.createElement(ClientApp),
+ {
+ onShellError(error) {
+ shellErrors.push(error.message);
+ },
+ },
+ ).pipe(fizzWritable);
+ });
+ assertConsoleErrorDev([
+ 'The render was aborted by the server without a reason.',
+ 'The render was aborted by the server without a reason.',
+ ]);
+
+ expect(shellErrors).toEqual([]);
+
+ const container = document.createElement('div');
+ await readInto(container, fizzReadable);
+ expect(getMeaningfulChildren(container)).toEqual(
+
+
loading 1...
+
loading 2...
+
hello world
+
,
+ );
+ });
});
diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js
index 2622b4e15cc8f..11b558c592abf 100644
--- a/packages/react-server/src/ReactFlightServer.js
+++ b/packages/react-server/src/ReactFlightServer.js
@@ -381,10 +381,11 @@ const PENDING = 0;
const COMPLETED = 1;
const ABORTED = 3;
const ERRORED = 4;
+const RENDERING = 5;
type Task = {
id: number,
- status: 0 | 1 | 3 | 4,
+ status: 0 | 1 | 3 | 4 | 5,
model: ReactClientValue,
ping: () => void,
toJSON: (key: string, value: ReactClientValue) => ReactJSONValue,
@@ -396,7 +397,7 @@ type Task = {
interface Reference {}
export type Request = {
- status: 0 | 1 | 2,
+ status: 0 | 1 | 2 | 3,
flushScheduled: boolean,
fatalError: mixed,
destination: null | Destination,
@@ -427,6 +428,8 @@ export type Request = {
didWarnForKey: null | WeakSet,
};
+const AbortSigil = {};
+
const {
TaintRegistryObjects,
TaintRegistryValues,
@@ -466,8 +469,9 @@ function defaultPostponeHandler(reason: string) {
}
const OPEN = 0;
-const CLOSING = 1;
-const CLOSED = 2;
+const ABORTING = 1;
+const CLOSING = 2;
+const CLOSED = 3;
export function createRequest(
model: ReactClientValue,
@@ -556,7 +560,6 @@ function serializeThenable(
task.implicitSlot,
request.abortableTasks,
);
-
if (__DEV__) {
// If this came from Flight, forward any debug info into this new row.
const debugInfo: ?ReactDebugInfo = (thenable: any)._debugInfo;
@@ -590,6 +593,15 @@ function serializeThenable(
return newTask.id;
}
default: {
+ if (request.status === ABORTING) {
+ // We can no longer accept any resolved values
+ newTask.status = ABORTED;
+ const errorId: number = (request.fatalError: any);
+ const model = stringify(serializeByValueID(errorId));
+ emitModelChunk(request, newTask.id, model);
+ request.abortableTasks.delete(newTask);
+ return newTask.id;
+ }
if (typeof thenable.status === 'string') {
// Only instrument the thenable if the status if not defined. If
// it's defined, but an unknown value, assume it's been instrumented by
@@ -1046,6 +1058,14 @@ function renderFunctionComponent(
const secondArg = undefined;
result = Component(props, secondArg);
}
+
+ if (request.status === ABORTING) {
+ // If we aborted during rendering we should interrupt the render but
+ // we don't need to provide an error because the renderer will encode
+ // the abort error as the reason.
+ throw AbortSigil;
+ }
+
if (
typeof result === 'object' &&
result !== null &&
@@ -1523,6 +1543,12 @@ function renderElement(
const init = type._init;
wrappedType = init(payload);
}
+ if (request.status === ABORTING) {
+ // lazy initializers are user code and could abort during render
+ // we don't wan to return any value resolved from the lazy initializer
+ // if it aborts so we interrupt rendering here
+ throw AbortSigil;
+ }
return renderElement(
request,
task,
@@ -1942,6 +1968,15 @@ function renderModel(
try {
return renderModelDestructive(request, task, parent, key, value);
} catch (thrownValue) {
+ // If the suspended/errored value was an element or lazy it can be reduced
+ // to a lazy reference, so that it doesn't error the parent.
+ const model = task.model;
+ const wasReactNode =
+ typeof model === 'object' &&
+ model !== null &&
+ ((model: any).$$typeof === REACT_ELEMENT_TYPE ||
+ (model: any).$$typeof === REACT_LAZY_TYPE);
+
const x =
thrownValue === SuspenseException
? // This is a special type of exception used for Suspense. For historical
@@ -1951,17 +1986,18 @@ function renderModel(
// later, once we deprecate the old API in favor of `use`.
getSuspendedThenable()
: thrownValue;
- // If the suspended/errored value was an element or lazy it can be reduced
- // to a lazy reference, so that it doesn't error the parent.
- const model = task.model;
- const wasReactNode =
- typeof model === 'object' &&
- model !== null &&
- ((model: any).$$typeof === REACT_ELEMENT_TYPE ||
- (model: any).$$typeof === REACT_LAZY_TYPE);
+
if (typeof x === 'object' && x !== null) {
// $FlowFixMe[method-unbinding]
if (typeof x.then === 'function') {
+ if (request.status === ABORTING) {
+ task.status = ABORTED;
+ const errorId: number = (request.fatalError: any);
+ if (wasReactNode) {
+ return serializeLazyID(errorId);
+ }
+ return serializeByValueID(errorId);
+ }
// Something suspended, we'll need to create a new task and resolve it later.
const newTask = createTask(
request,
@@ -2004,6 +2040,15 @@ function renderModel(
}
}
+ if (thrownValue === AbortSigil) {
+ task.status = ABORTED;
+ const errorId: number = (request.fatalError: any);
+ if (wasReactNode) {
+ return serializeLazyID(errorId);
+ }
+ return serializeByValueID(errorId);
+ }
+
// Restore the context. We assume that this will be restored by the inner
// functions in case nothing throws so we don't use "finally" here.
task.keyPath = prevKeyPath;
@@ -2147,6 +2192,12 @@ function renderModelDestructive(
const init = lazy._init;
resolvedModel = init(payload);
}
+ if (request.status === ABORTING) {
+ // lazy initializers are user code and could abort during render
+ // we don't wan to return any value resolved from the lazy initializer
+ // if it aborts so we interrupt rendering here
+ throw AbortSigil;
+ }
if (__DEV__) {
const debugInfo: ?ReactDebugInfo = lazy._debugInfo;
if (debugInfo) {
@@ -3262,6 +3313,7 @@ function retryTask(request: Request, task: Task): void {
}
const prevDebugID = debugID;
+ task.status = RENDERING;
try {
// Track the root so we know that we have to emit this object even though it
@@ -3328,10 +3380,19 @@ function retryTask(request: Request, task: Task): void {
if (typeof x === 'object' && x !== null) {
// $FlowFixMe[method-unbinding]
if (typeof x.then === 'function') {
+ if (request.status === ABORTING) {
+ request.abortableTasks.delete(task);
+ task.status = ABORTED;
+ const errorId: number = (request.fatalError: any);
+ const model = stringify(serializeByValueID(errorId));
+ emitModelChunk(request, task.id, model);
+ return;
+ }
// Something suspended again, let's pick it back up later.
+ task.status = PENDING;
+ task.thenableState = getThenableStateAfterSuspending();
const ping = task.ping;
x.then(ping, ping);
- task.thenableState = getThenableStateAfterSuspending();
return;
} else if (enablePostpone && x.$$typeof === REACT_POSTPONE_TYPE) {
request.abortableTasks.delete(task);
@@ -3342,6 +3403,16 @@ function retryTask(request: Request, task: Task): void {
return;
}
}
+
+ if (x === AbortSigil) {
+ request.abortableTasks.delete(task);
+ task.status = ABORTED;
+ const errorId: number = (request.fatalError: any);
+ const model = stringify(serializeByValueID(errorId));
+ emitModelChunk(request, task.id, model);
+ return;
+ }
+
request.abortableTasks.delete(task);
task.status = ERRORED;
const digest = logRecoverableError(request, x);
@@ -3399,6 +3470,10 @@ function performWork(request: Request): void {
}
function abortTask(task: Task, request: Request, errorId: number): void {
+ if (task.status === RENDERING) {
+ // This task will be aborted by the render
+ return;
+ }
task.status = ABORTED;
// Instead of emitting an error per task.id, we emit a model that only
// has a single value referencing the error.
@@ -3484,6 +3559,7 @@ function flushCompletedChunks(
if (enableTaint) {
cleanupTaintQueue(request);
}
+ request.status = CLOSED;
close(destination);
request.destination = null;
}
@@ -3547,12 +3623,14 @@ export function stopFlowing(request: Request): void {
// This is called to early terminate a request. It creates an error at all pending tasks.
export function abort(request: Request, reason: mixed): void {
try {
+ request.status = ABORTING;
const abortableTasks = request.abortableTasks;
// We have tasks to abort. We'll emit one error row and then emit a reference
// to that row from every row that's still remaining.
if (abortableTasks.size > 0) {
request.pendingChunks++;
const errorId = request.nextChunkId++;
+ request.fatalError = errorId;
if (
enablePostpone &&
typeof reason === 'object' &&
@@ -3568,6 +3646,10 @@ export function abort(request: Request, reason: mixed): void {
? new Error(
'The render was aborted by the server without a reason.',
)
+ : typeof reason === 'object' &&
+ reason !== null &&
+ typeof reason.then === 'function'
+ ? new Error('The render was aborted by the server with a promise.')
: reason;
const digest = logRecoverableError(request, error);
emitErrorChunk(request, errorId, digest, error);
@@ -3594,6 +3676,10 @@ export function abort(request: Request, reason: mixed): void {
? new Error(
'The render was aborted by the server without a reason.',
)
+ : typeof reason === 'object' &&
+ reason !== null &&
+ typeof reason.then === 'function'
+ ? new Error('The render was aborted by the server with a promise.')
: reason;
}
abortListeners.forEach(callback => callback(error));
diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json
index b157b6eaef7d2..ef4ae75a6d634 100644
--- a/scripts/error-codes/codes.json
+++ b/scripts/error-codes/codes.json
@@ -514,5 +514,6 @@
"526": "Could not reference an opaque temporary reference. This is likely due to misconfiguring the temporaryReferences options on the server.",
"527": "Incompatible React versions: The \"react\" and \"react-dom\" packages must have the exact same version. Instead got:\n - react: %s\n - react-dom: %s\nLearn more: https://react.dev/warnings/version-mismatch",
"528": "Expected not to update to be updated to a stylesheet with precedence. Check the `rel`, `href`, and `precedence` props of this component. Alternatively, check whether two different components render in the same slot or share the same key.%s",
- "529": "Expected stylesheet with precedence to not be updated to a different kind of . Check the `rel`, `href`, and `precedence` props of this component. Alternatively, check whether two different components render in the same slot or share the same key.%s"
+ "529": "Expected stylesheet with precedence to not be updated to a different kind of . Check the `rel`, `href`, and `precedence` props of this component. Alternatively, check whether two different components render in the same slot or share the same key.%s",
+ "530": "The render was aborted by the server with a promise."
}