[Flight] model halted references explicitly (#30731)
using infinitely suspending promises isn't right because this will parse as a promise which is only appropriate if the value we're halting at is a promise. Instead we need to have a special marker type that says this reference will never resolve. Additionally flight client needs to not error any halted references when the stream closes because they will otherwise appear as an error addresses: https://github.com/facebook/react/pull/30705#discussion_r1720479974
Josh Story committed
Aug 19, 2024 at 11:24 UTC
9d082b550086e6be5f54872d518efa14303491db
3 files changed
+149
-24
packages/react-client/src/ReactFlightClient.js
+23
@@ -46,6 +46,7 @@ import {
46
enableRefAsProp,
47
enableFlightReadableStream,
48
enableOwnerStacks,
49
+ enableHalt,
50
} from 'shared/ReactFeatureFlags';
51
52
import {
@@ -1986,6 +1987,20 @@ function resolvePostponeDev(
1987
}
1988
}
1989
1990
+function resolveBlocked(response: Response, id: number): void {
1991
+ const chunks = response._chunks;
1992
+ const chunk = chunks.get(id);
1993
+ if (!chunk) {
1994
+ chunks.set(id, createBlockedChunk(response));
1995
+ } else if (chunk.status === PENDING) {
1996
+ // This chunk as contructed via other means but it is actually a blocked chunk
1997
+ // so we update it here. We check the status because it might have been aborted
1998
+ // before we attempted to resolve it.
1999
+ const blockedChunk: BlockedChunk<mixed> = (chunk: any);
2000
+ blockedChunk.status = BLOCKED;
2001
+ }
2002
+}
2003
+
2004
function resolveHint<Code: HintCode>(
2005
response: Response,
2006
code: Code,
@@ -2612,6 +2627,13 @@ function processFullStringRow(
2627
}
2628
}
2629
// Fallthrough
2630
+ case 35 /* "#" */: {
2631
+ if (enableHalt) {
2632
+ resolveBlocked(response, id);
2633
+ return;
2634
+ }
2635
+ }
2636
+ // Fallthrough
2637
default: /* """ "{" "[" "t" "f" "n" "0" - "9" */ {
2638
// We assume anything else is JSON.
2639
resolveModel(response, id, row);
@@ -2668,6 +2690,7 @@ export function processBinaryChunk(
2690
i++;
2691
} else if (
2692
(resolvedRowTag > 64 && resolvedRowTag < 91) /* "A"-"Z" */ ||
2693
+ resolvedRowTag === 35 /* "#" */ ||
2694
resolvedRowTag === 114 /* "r" */ ||
2695
resolvedRowTag === 120 /* "x" */
2696
) {
packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js
+101
@@ -2856,4 +2856,105 @@ describe('ReactFlightDOM', () => {
2856
jest.advanceTimersByTime('100');
2857
expect(await race).toBe('timeout');
2858
});
2859
+
2860
+ // @gate enableHalt
2861
+ it('will halt unfinished chunks inside Suspense when aborting a prerender', async () => {
2862
+ const controller = new AbortController();
2863
+ function ComponentThatAborts() {
2864
+ controller.abort();
2865
+ return null;
2866
+ }
2867
+
2868
+ async function Greeting() {
2869
+ await 1;
2870
+ return 'hello world';
2871
+ }
2872
+
2873
+ async function Farewell() {
2874
+ return 'goodbye world';
2875
+ }
2876
+
2877
+ async function Wrapper() {
2878
+ return (
2879
+ <Suspense fallback="loading too...">
2880
+ <ComponentThatAborts />
2881
+ </Suspense>
2882
+ );
2883
+ }
2884
+
2885
+ function App() {
2886
+ return (
2887
+ <div>
2888
+ <Suspense fallback="loading...">
2889
+ <Greeting />
2890
+ </Suspense>
2891
+ <Wrapper />
2892
+ <Suspense fallback="loading three...">
2893
+ <Farewell />
2894
+ </Suspense>
2895
+ </div>
2896
+ );
2897
+ }
2898
+
2899
+ const errors = [];
2900
+ const {pendingResult} = await serverAct(() => {
2901
+ return {
2902
+ pendingResult: ReactServerDOMStaticServer.prerenderToNodeStream(
2903
+ <App />,
2904
+ {},
2905
+ {
2906
+ onError(x) {
2907
+ errors.push(x);
2908
+ },
2909
+ signal: controller.signal,
2910
+ },
2911
+ ),
2912
+ };
2913
+ });
2914
+
2915
+ controller.abort();
2916
+
2917
+ const {prelude} = await pendingResult;
2918
+ expect(errors).toEqual([]);
2919
+
2920
+ const response = ReactServerDOMClient.createFromReadableStream(
2921
+ Readable.toWeb(prelude),
2922
+ );
2923
+
2924
+ const {writable: fizzWritable, readable: fizzReadable} = getTestStream();
2925
+
2926
+ function ClientApp() {
2927
+ return use(response);
2928
+ }
2929
+ let abortFizz;
2930
+ await serverAct(async () => {
2931
+ const {pipe, abort} = ReactDOMFizzServer.renderToPipeableStream(
2932
+ React.createElement(ClientApp),
2933
+ {
2934
+ onError(error, errorInfo) {
2935
+ errors.push(error);
2936
+ },
2937
+ },
2938
+ );
2939
+ pipe(fizzWritable);
2940
+ abortFizz = abort;
2941
+ });
2942
+
2943
+ await serverAct(() => {
2944
+ abortFizz('boom');
2945
+ });
2946
+
2947
+ // one error per boundary
2948
+ expect(errors).toEqual(['boom', 'boom', 'boom']);
2949
+
2950
+ const container = document.createElement('div');
2951
+ await readInto(container, fizzReadable);
2952
+ expect(getMeaningfulChildren(container)).toEqual(
2953
+ <div>
2954
+ {'loading...'}
2955
+ {'loading too...'}
2956
+ {'loading three...'}
2957
+ </div>,
2958
+ );
2959
+ });
2960
});
packages/react-server/src/ReactFlightServer.js
+25
-24
@@ -617,7 +617,7 @@ function serializeThenable(
617
request.abortableTasks.delete(newTask);
618
newTask.status = ABORTED;
619
if (enableHalt && request.fatalError === haltSymbol) {
620
- emitModelChunk(request, newTask.id, reusableInfinitePromiseModel);
620
+ emitBlockedChunk(request, newTask.id);
621
} else {
622
const errorId: number = (request.fatalError: any);
623
const model = stringify(serializeByValueID(errorId));
@@ -1820,7 +1820,6 @@ function serializeLazyID(id: number): string {
1820
function serializeInfinitePromise(): string {
1821
return '$@';
1822
}
1823
-const reusableInfinitePromiseModel = stringify(serializeInfinitePromise());
1823
1824
function serializePromiseID(id: number): string {
1825
return '$@' + id.toString(16);
@@ -2208,9 +2207,6 @@ function renderModel(
2207
if (typeof x.then === 'function') {
2208
if (request.status === ABORTING) {
2209
task.status = ABORTED;
2211
- if (enableHalt && request.fatalError === haltSymbol) {
2212
- return serializeInfinitePromise();
2213
- }
2210
const errorId: number = (request.fatalError: any);
2211
if (wasReactNode) {
2212
return serializeLazyID(errorId);
@@ -2264,9 +2260,6 @@ function renderModel(
2260
2261
if (request.status === ABORTING) {
2262
task.status = ABORTED;
2267
- if (enableHalt && request.fatalError === haltSymbol) {
2268
- return serializeInfinitePromise();
2269
- }
2263
const errorId: number = (request.fatalError: any);
2264
if (wasReactNode) {
2265
return serializeLazyID(errorId);
@@ -3008,6 +3001,12 @@ function emitPostponeChunk(
3001
request.completedErrorChunks.push(processedChunk);
3002
}
3003
3004
+function emitBlockedChunk(request: Request, id: number): void {
3005
+ const row = serializeRowHeader('#', id) + '\n';
3006
+ const processedChunk = stringToChunk(row);
3007
+ request.completedErrorChunks.push(processedChunk);
3008
+}
3009
+
3010
function emitErrorChunk(
3011
request: Request,
3012
id: number,
@@ -3757,7 +3756,7 @@ function retryTask(request: Request, task: Task): void {
3756
request.abortableTasks.delete(task);
3757
task.status = ABORTED;
3758
if (enableHalt && request.fatalError === haltSymbol) {
3760
- emitModelChunk(request, task.id, reusableInfinitePromiseModel);
3759
+ emitBlockedChunk(request, task.id);
3760
} else {
3761
const errorId: number = (request.fatalError: any);
3762
const model = stringify(serializeByValueID(errorId));
@@ -3785,7 +3784,7 @@ function retryTask(request: Request, task: Task): void {
3784
request.abortableTasks.delete(task);
3785
task.status = ABORTED;
3786
if (enableHalt && request.fatalError === haltSymbol) {
3788
- emitModelChunk(request, task.id, reusableInfinitePromiseModel);
3787
+ emitBlockedChunk(request, task.id);
3788
} else {
3789
const errorId: number = (request.fatalError: any);
3790
const model = stringify(serializeByValueID(errorId));
@@ -3830,6 +3829,7 @@ function performWork(request: Request): void {
3829
currentRequest = request;
3830
prepareToUseHooksForRequest(request);
3831
3832
+ const hadAbortableTasks = request.abortableTasks.size > 0;
3833
try {
3834
const pingedTasks = request.pingedTasks;
3835
request.pingedTasks = [];
@@ -3840,10 +3840,11 @@ function performWork(request: Request): void {
3840
if (request.destination !== null) {
3841
flushCompletedChunks(request, request.destination);
3842
}
3843
- if (request.abortableTasks.size === 0) {
3844
- // we're done rendering
3845
- const onAllReady = request.onAllReady;
3846
- onAllReady();
3843
+ if (hadAbortableTasks && request.abortableTasks.size === 0) {
3844
+ // We can ping after completing but if this happens there already
3845
+ // wouldn't be any abortable tasks. So we only call allReady after
3846
+ // the work which actually completed the last pending task
3847
+ allReady(request);
3848
}
3849
} catch (error) {
3850
logRecoverableError(request, error, null);
@@ -3868,15 +3869,6 @@ function abortTask(task: Task, request: Request, errorId: number): void {
3869
request.completedErrorChunks.push(processedChunk);
3870
}
3871
3871
-function haltTask(task: Task, request: Request): void {
3872
- if (task.status === RENDERING) {
3873
- // This task will be aborted by the render
3874
- return;
3875
- }
3876
- task.status = ABORTED;
3877
- emitModelChunk(request, task.id, reusableInfinitePromiseModel);
3878
-}
3879
-
3872
function flushCompletedChunks(
3873
request: Request,
3874
destination: Destination,
@@ -4055,6 +4047,7 @@ export function abort(request: Request, reason: mixed): void {
4047
}
4048
abortableTasks.forEach(task => abortTask(task, request, errorId));
4049
abortableTasks.clear();
4050
+ allReady(request);
4051
}
4052
const abortListeners = request.abortListeners;
4053
if (abortListeners.size > 0) {
@@ -4110,8 +4103,11 @@ export function halt(request: Request, reason: mixed): void {
4103
// to that row from every row that's still remaining.
4104
if (abortableTasks.size > 0) {
4105
request.pendingChunks++;
4113
- abortableTasks.forEach(task => haltTask(task, request));
4106
+ const errorId = request.nextChunkId++;
4107
+ emitBlockedChunk(request, errorId);
4108
+ abortableTasks.forEach(task => abortTask(task, request, errorId));
4109
abortableTasks.clear();
4110
+ allReady(request);
4111
}
4112
const abortListeners = request.abortListeners;
4113
if (abortListeners.size > 0) {
@@ -4126,3 +4122,8 @@ export function halt(request: Request, reason: mixed): void {
4122
fatalError(request, error);
4123
}
4124
}
4125
+
4126
+function allReady(request: Request) {
4127
+ const onAllReady = request.onAllReady;
4128
+ onAllReady();
4129
+}