Skip to content

Commit 19e025f

Browse files
committed
[flight] When halting call onError/onPostpone
Halt was originally implemented as an alternative to error handling and thus halted reasons were not exposed through any observability event like onError or onPostpone. We could add something like onAbort or onHalt in it's place but it's not clear that this is particularly well motivated. Instead in this change we update halt semantics to still call onError and onPostpone with the abort reason. So a halt doesn't change what you can observe but it does change the serialization model. So while you will see errors through onError they won't propagate to the consumer as errors.
1 parent 78a72fc commit 19e025f

File tree

2 files changed

+130
-95
lines changed

2 files changed

+130
-95
lines changed

packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2746,6 +2746,7 @@ describe('ReactFlightDOM', () => {
27462746
}
27472747

27482748
const controller = new AbortController();
2749+
const errors = [];
27492750
const {pendingResult} = await serverAct(async () => {
27502751
// destructure trick to avoid the act scope from awaiting the returned value
27512752
return {
@@ -2754,15 +2755,20 @@ describe('ReactFlightDOM', () => {
27542755
webpackMap,
27552756
{
27562757
signal: controller.signal,
2758+
onError(err) {
2759+
errors.push(err);
2760+
},
27572761
},
27582762
),
27592763
};
27602764
});
27612765

2762-
controller.abort();
2766+
controller.abort('boom');
27632767
resolveGreeting();
27642768
const {prelude} = await pendingResult;
27652769

2770+
expect(errors).toEqual(['boom']);
2771+
27662772
const preludeWeb = Readable.toWeb(prelude);
27672773
const response = ReactServerDOMClient.createFromReadableStream(preludeWeb);
27682774

@@ -2772,7 +2778,7 @@ describe('ReactFlightDOM', () => {
27722778
return use(response);
27732779
}
27742780

2775-
const errors = [];
2781+
errors.length = 0;
27762782
let abortFizz;
27772783
await serverAct(async () => {
27782784
const {pipe, abort} = ReactDOMFizzServer.renderToPipeableStream(
@@ -2788,10 +2794,10 @@ describe('ReactFlightDOM', () => {
27882794
});
27892795

27902796
await serverAct(() => {
2791-
abortFizz('boom');
2797+
abortFizz('bam');
27922798
});
27932799

2794-
expect(errors).toEqual(['boom']);
2800+
expect(errors).toEqual(['bam']);
27952801

27962802
const container = document.createElement('div');
27972803
await readInto(container, fizzReadable);
@@ -2857,10 +2863,11 @@ describe('ReactFlightDOM', () => {
28572863
expect(await race).toBe('timeout');
28582864
});
28592865

2866+
// @gate enableHalt
28602867
it('will halt unfinished chunks inside Suspense when aborting a prerender', async () => {
28612868
const controller = new AbortController();
28622869
function ComponentThatAborts() {
2863-
controller.abort();
2870+
controller.abort('boom');
28642871
return null;
28652872
}
28662873

@@ -2900,10 +2907,8 @@ describe('ReactFlightDOM', () => {
29002907
};
29012908
});
29022909

2903-
controller.abort();
2904-
29052910
const {prelude} = await pendingResult;
2906-
expect(errors).toEqual([]);
2911+
expect(errors).toEqual(['boom']);
29072912
const response = ReactServerDOMClient.createFromReadableStream(
29082913
Readable.toWeb(prelude),
29092914
);
@@ -2913,6 +2918,7 @@ describe('ReactFlightDOM', () => {
29132918
function ClientApp() {
29142919
return use(response);
29152920
}
2921+
errors.length = 0;
29162922
let abortFizz;
29172923
await serverAct(async () => {
29182924
const {pipe, abort} = ReactDOMFizzServer.renderToPipeableStream(

packages/react-server/src/ReactFlightServer.js

Lines changed: 116 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -753,30 +753,32 @@ function serializeReadableStream(
753753
}
754754
aborted = true;
755755
request.abortListeners.delete(error);
756-
757-
let cancelWith: mixed;
758-
if (enableHalt && request.fatalError === haltSymbol) {
759-
cancelWith = reason;
760-
} else if (
756+
if (
761757
enablePostpone &&
762758
typeof reason === 'object' &&
763759
reason !== null &&
764760
(reason: any).$$typeof === REACT_POSTPONE_TYPE
765761
) {
766-
cancelWith = reason;
767762
const postponeInstance: Postpone = (reason: any);
768763
logPostpone(request, postponeInstance.message, streamTask);
769-
emitPostponeChunk(request, streamTask.id, postponeInstance);
770-
enqueueFlush(request);
764+
if (enableHalt && request.fatalError === haltSymbol) {
765+
request.pendingChunks--;
766+
} else {
767+
emitPostponeChunk(request, streamTask.id, postponeInstance);
768+
enqueueFlush(request);
769+
}
771770
} else {
772-
cancelWith = reason;
773771
const digest = logRecoverableError(request, reason, streamTask);
774-
emitErrorChunk(request, streamTask.id, digest, reason);
775-
enqueueFlush(request);
772+
if (enableHalt && request.fatalError === haltSymbol) {
773+
request.pendingChunks--;
774+
} else {
775+
emitErrorChunk(request, streamTask.id, digest, reason);
776+
enqueueFlush(request);
777+
}
776778
}
777779

778780
// $FlowFixMe should be able to pass mixed
779-
reader.cancel(cancelWith).then(error, error);
781+
reader.cancel(reason).then(error, error);
780782
}
781783

782784
request.abortListeners.add(error);
@@ -880,30 +882,33 @@ function serializeAsyncIterable(
880882
}
881883
aborted = true;
882884
request.abortListeners.delete(error);
883-
let throwWith: mixed;
884-
if (enableHalt && request.fatalError === haltSymbol) {
885-
throwWith = reason;
886-
} else if (
885+
if (
887886
enablePostpone &&
888887
typeof reason === 'object' &&
889888
reason !== null &&
890889
(reason: any).$$typeof === REACT_POSTPONE_TYPE
891890
) {
892-
throwWith = reason;
893891
const postponeInstance: Postpone = (reason: any);
894892
logPostpone(request, postponeInstance.message, streamTask);
895-
emitPostponeChunk(request, streamTask.id, postponeInstance);
896-
enqueueFlush(request);
893+
if (enableHalt && request.fatalError === haltSymbol) {
894+
request.pendingChunks--;
895+
} else {
896+
emitPostponeChunk(request, streamTask.id, postponeInstance);
897+
enqueueFlush(request);
898+
}
897899
} else {
898-
throwWith = reason;
899900
const digest = logRecoverableError(request, reason, streamTask);
900-
emitErrorChunk(request, streamTask.id, digest, reason);
901-
enqueueFlush(request);
901+
if (enableHalt && request.fatalError === haltSymbol) {
902+
request.pendingChunks--;
903+
} else {
904+
emitErrorChunk(request, streamTask.id, digest, reason);
905+
enqueueFlush(request);
906+
}
902907
}
903908
if (typeof (iterator: any).throw === 'function') {
904909
// The iterator protocol doesn't necessarily include this but a generator do.
905910
// $FlowFixMe should be able to pass mixed
906-
iterator.throw(throwWith).then(error, error);
911+
iterator.throw(reason).then(error, error);
907912
}
908913
}
909914
request.abortListeners.add(error);
@@ -2095,18 +2100,31 @@ function serializeBlob(request: Request, blob: Blob): string {
20952100
}
20962101
aborted = true;
20972102
request.abortListeners.delete(error);
2098-
let cancelWith: mixed;
2099-
if (enableHalt && request.fatalError === haltSymbol) {
2100-
cancelWith = reason;
2103+
if (
2104+
enablePostpone &&
2105+
typeof reason === 'object' &&
2106+
reason !== null &&
2107+
(reason: any).$$typeof === REACT_POSTPONE_TYPE
2108+
) {
2109+
const postponeInstance: Postpone = (reason: any);
2110+
logPostpone(request, postponeInstance.message, newTask);
2111+
if (enableHalt && request.fatalError === haltSymbol) {
2112+
request.pendingChunks--;
2113+
} else {
2114+
emitPostponeChunk(request, newTask.id, postponeInstance);
2115+
enqueueFlush(request);
2116+
}
21012117
} else {
2102-
cancelWith = reason;
21032118
const digest = logRecoverableError(request, reason, newTask);
2104-
emitErrorChunk(request, newTask.id, digest, reason);
2105-
request.abortableTasks.delete(newTask);
2106-
enqueueFlush(request);
2119+
if (enableHalt && request.fatalError === haltSymbol) {
2120+
request.pendingChunks--;
2121+
} else {
2122+
emitErrorChunk(request, newTask.id, digest, reason);
2123+
enqueueFlush(request);
2124+
}
21072125
}
21082126
// $FlowFixMe should be able to pass mixed
2109-
reader.cancel(cancelWith).then(error, error);
2127+
reader.cancel(reason).then(error, error);
21102128
}
21112129

21122130
request.abortListeners.add(error);
@@ -3998,14 +4016,15 @@ export function stopFlowing(request: Request): void {
39984016

39994017
// This is called to early terminate a request. It creates an error at all pending tasks.
40004018
export function abort(request: Request, reason: mixed): void {
4019+
if (request.status === OPEN) {
4020+
request.status = ABORTING;
4021+
}
40014022
try {
4002-
if (request.status === OPEN) {
4003-
request.status = ABORTING;
4004-
}
40054023
const abortableTasks = request.abortableTasks;
40064024
// We have tasks to abort. We'll emit one error row and then emit a reference
40074025
// to that row from every row that's still remaining.
40084026
if (abortableTasks.size > 0) {
4027+
request.status = ABORTING;
40094028
request.pendingChunks++;
40104029
const errorId = request.nextChunkId++;
40114030
request.fatalError = errorId;
@@ -4019,54 +4038,14 @@ export function abort(request: Request, reason: mixed): void {
40194038
logPostpone(request, postponeInstance.message, null);
40204039
emitPostponeChunk(request, errorId, postponeInstance);
40214040
} else {
4022-
const error =
4023-
reason === undefined
4024-
? new Error(
4025-
'The render was aborted by the server without a reason.',
4026-
)
4027-
: typeof reason === 'object' &&
4028-
reason !== null &&
4029-
typeof reason.then === 'function'
4030-
? new Error(
4031-
'The render was aborted by the server with a promise.',
4032-
)
4033-
: reason;
4041+
const error = resolveAbortError(reason);
40344042
const digest = logRecoverableError(request, error, null);
40354043
emitErrorChunk(request, errorId, digest, error);
40364044
}
40374045
abortableTasks.forEach(task => abortTask(task, request, errorId));
40384046
abortableTasks.clear();
40394047
}
4040-
const abortListeners = request.abortListeners;
4041-
if (abortListeners.size > 0) {
4042-
let error;
4043-
if (
4044-
enablePostpone &&
4045-
typeof reason === 'object' &&
4046-
reason !== null &&
4047-
(reason: any).$$typeof === REACT_POSTPONE_TYPE
4048-
) {
4049-
// We aborted with a Postpone but since we're passing this to an
4050-
// external handler, passing this object would leak it outside React.
4051-
// We create an alternative reason for it instead.
4052-
error = new Error('The render was aborted due to being postponed.');
4053-
} else {
4054-
error =
4055-
reason === undefined
4056-
? new Error(
4057-
'The render was aborted by the server without a reason.',
4058-
)
4059-
: typeof reason === 'object' &&
4060-
reason !== null &&
4061-
typeof reason.then === 'function'
4062-
? new Error(
4063-
'The render was aborted by the server with a promise.',
4064-
)
4065-
: reason;
4066-
}
4067-
abortListeners.forEach(callback => callback(error));
4068-
abortListeners.clear();
4069-
}
4048+
abortAnyListeners(reason, request.abortListeners);
40704049
if (request.destination !== null) {
40714050
flushCompletedChunks(request, request.destination);
40724051
}
@@ -4082,23 +4061,32 @@ const haltSymbol = Symbol('halt');
40824061
// This is called to stop rendering without erroring. All unfinished work is represented Promises
40834062
// that never resolve.
40844063
export function halt(request: Request, reason: mixed): void {
4064+
if (request.status === OPEN) {
4065+
request.status = ABORTING;
4066+
}
4067+
request.fatalError = haltSymbol;
40854068
try {
4086-
if (request.status === OPEN) {
4087-
request.status = ABORTING;
4088-
}
4089-
request.fatalError = haltSymbol;
40904069
const abortableTasks = request.abortableTasks;
4091-
// We have tasks to abort. We'll emit one error row and then emit a reference
4092-
// to that row from every row that's still remaining.
40934070
if (abortableTasks.size > 0) {
4071+
// We have tasks to halt. We will log the error or postpone but we don't
4072+
// emit an error or postpone chunk. Instead we will emit a reference that
4073+
// never resolves on the client.
4074+
if (
4075+
enablePostpone &&
4076+
typeof reason === 'object' &&
4077+
reason !== null &&
4078+
(reason: any).$$typeof === REACT_POSTPONE_TYPE
4079+
) {
4080+
const postponeInstance: Postpone = (reason: any);
4081+
logPostpone(request, postponeInstance.message, null);
4082+
} else {
4083+
const error = resolveAbortError(reason);
4084+
logRecoverableError(request, error, null);
4085+
}
40944086
abortableTasks.forEach(task => haltTask(task, request));
40954087
abortableTasks.clear();
40964088
}
4097-
const abortListeners = request.abortListeners;
4098-
if (abortListeners.size > 0) {
4099-
abortListeners.forEach(callback => callback(reason));
4100-
abortListeners.clear();
4101-
}
4089+
abortAnyListeners(reason, request.abortListeners);
41024090
if (request.destination !== null) {
41034091
flushCompletedChunks(request, request.destination);
41044092
}
@@ -4109,6 +4097,47 @@ export function halt(request: Request, reason: mixed): void {
41094097
}
41104098
}
41114099

4100+
function resolveAbortError(reason: mixed): mixed {
4101+
return reason === undefined
4102+
? new Error('The render was aborted by the server without a reason.')
4103+
: typeof reason === 'object' &&
4104+
reason !== null &&
4105+
typeof reason.then === 'function'
4106+
? new Error('The render was aborted by the server with a promise.')
4107+
: reason;
4108+
}
4109+
4110+
function abortAnyListeners(
4111+
reason: mixed,
4112+
listeners: Set<(reason: mixed) => void>,
4113+
) {
4114+
if (listeners.size > 0) {
4115+
let error;
4116+
if (
4117+
enablePostpone &&
4118+
typeof reason === 'object' &&
4119+
reason !== null &&
4120+
(reason: any).$$typeof === REACT_POSTPONE_TYPE
4121+
) {
4122+
// We aborted with a Postpone but since we're passing this to an
4123+
// external handler, passing this object would leak it outside React.
4124+
// We create an alternative reason for it instead.
4125+
error = new Error('The render was aborted due to being postponed.');
4126+
} else {
4127+
error =
4128+
reason === undefined
4129+
? new Error('The render was aborted by the server without a reason.')
4130+
: typeof reason === 'object' &&
4131+
reason !== null &&
4132+
typeof reason.then === 'function'
4133+
? new Error('The render was aborted by the server with a promise.')
4134+
: reason;
4135+
}
4136+
listeners.forEach(callback => callback(error));
4137+
listeners.clear();
4138+
}
4139+
}
4140+
41124141
function allReady(request: Request) {
41134142
const onAllReady = request.onAllReady;
41144143
onAllReady();

0 commit comments

Comments
 (0)