@samitouri / QOS-React / commits / 9d082b5500

[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 +}