@samitouri / QOS-React / commits / 92d26c8e93

[Flight] When halting omit any reference rather than refer to a shared missing chunk (#30750)

[Flight] When halting omit any reference rather than refer to a shared missing chunk (#30750) When aborting a prerender we should leave references unfulfilled, not share a common unfullfilled reference. functionally today this doesn't matter because we don't have resuming but the semantic is that the row was not available when the abort happened and in a resume the row should fill in. But by pointing each task to a common unfulfilled chunk we lose the ability for these references to resolves to distinct values on resume.

Josh Story committed Aug 20, 2024 at 10:22 UTC 92d26c8e93a88ca41338d3509b4324ad19a89c1e
5 files changed +153 -58
packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js
+25 -5
@@ -2797,7 +2797,16 @@ describe('ReactFlightDOM', () => {
2797 abortFizz('bam');
2798 });
2799
2800 - expect(errors).toEqual(['bam']);
2800 + if (__DEV__) {
2801 + expect(errors).toEqual([new Error('Connection closed.')]);
2802 + } else {
2803 + // This is likely a bug. In Dev we get a connection closed error
2804 + // because the debug info creates a chunk that has a pending status
2805 + // and when the stream finishes we error if any chunks are still pending.
2806 + // In production there is no debug info so the missing chunk is never instantiated
2807 + // because nothing triggers model evaluation before the stream completes
2808 + expect(errors).toEqual(['bam']);
2809 + }
2810
2811 const container = document.createElement('div');
2812 await readInto(container, fizzReadable);
@@ -2919,10 +2928,11 @@ describe('ReactFlightDOM', () => {
2928 });
2929
2930 const {prelude} = await pendingResult;
2931 +
2932 expect(errors).toEqual(['boom']);
2923 - const response = ReactServerDOMClient.createFromReadableStream(
2924 - Readable.toWeb(prelude),
2925 - );
2933 +
2934 + const preludeWeb = Readable.toWeb(prelude);
2935 + const response = ReactServerDOMClient.createFromReadableStream(preludeWeb);
2936
2937 const {writable: fizzWritable, readable: fizzReadable} = getTestStream();
2938
@@ -2949,7 +2959,17 @@ describe('ReactFlightDOM', () => {
2959 });
2960
2961 // one error per boundary
2952 - expect(errors).toEqual(['boom', 'boom', 'boom']);
2962 + if (__DEV__) {
2963 + const err = new Error('Connection closed.');
2964 + expect(errors).toEqual([err, err, err]);
2965 + } else {
2966 + // This is likely a bug. In Dev we get a connection closed error
2967 + // because the debug info creates a chunk that has a pending status
2968 + // and when the stream finishes we error if any chunks are still pending.
2969 + // In production there is no debug info so the missing chunk is never instantiated
2970 + // because nothing triggers model evaluation before the stream completes
2971 + expect(errors).toEqual(['boom', 'boom', 'boom']);
2972 + }
2973
2974 const container = document.createElement('div');
2975 await readInto(container, fizzReadable);
packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js
+18 -2
@@ -2454,12 +2454,28 @@ describe('ReactFlightDOMBrowser', () => {
2454 passThrough(prelude),
2455 );
2456 const container = document.createElement('div');
2457 - const root = ReactDOMClient.createRoot(container);
2457 + errors.length = 0;
2458 + const root = ReactDOMClient.createRoot(container, {
2459 + onUncaughtError(err) {
2460 + errors.push(err);
2461 + },
2462 + });
2463
2464 await act(() => {
2465 root.render(<ClientRoot response={response} />);
2466 });
2467
2463 - expect(container.innerHTML).toBe('<div>loading...</div>');
2468 + if (__DEV__) {
2469 + expect(errors).toEqual([new Error('Connection closed.')]);
2470 + expect(container.innerHTML).toBe('');
2471 + } else {
2472 + // This is likely a bug. In Dev we get a connection closed error
2473 + // because the debug info creates a chunk that has a pending status
2474 + // and when the stream finishes we error if any chunks are still pending.
2475 + // In production there is no debug info so the missing chunk is never instantiated
2476 + // because nothing triggers model evaluation before the stream completes
2477 + expect(errors).toEqual([]);
2478 + expect(container.innerHTML).toBe('<div>loading...</div>');
2479 + }
2480 });
2481 });
packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js
+10 -1
@@ -1172,7 +1172,16 @@ describe('ReactFlightDOMEdge', () => {
1172 ),
1173 );
1174 fizzController.abort('bam');
1175 - expect(errors).toEqual(['bam']);
1175 + if (__DEV__) {
1176 + expect(errors).toEqual([new Error('Connection closed.')]);
1177 + } else {
1178 + // This is likely a bug. In Dev we get a connection closed error
1179 + // because the debug info creates a chunk that has a pending status
1180 + // and when the stream finishes we error if any chunks are still pending.
1181 + // In production there is no debug info so the missing chunk is never instantiated
1182 + // because nothing triggers model evaluation before the stream completes
1183 + expect(errors).toEqual(['bam']);
1184 + }
1185 // Should still match the result when parsed
1186 const result = await readResult(ssrStream);
1187 const div = document.createElement('div');
packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js
+10 -1
@@ -509,7 +509,16 @@ describe('ReactFlightDOMNode', () => {
509 ),
510 );
511 ssrStream.abort('bam');
512 - expect(errors).toEqual(['bam']);
512 + if (__DEV__) {
513 + expect(errors).toEqual([new Error('Connection closed.')]);
514 + } else {
515 + // This is likely a bug. In Dev we get a connection closed error
516 + // because the debug info creates a chunk that has a pending status
517 + // and when the stream finishes we error if any chunks are still pending.
518 + // In production there is no debug info so the missing chunk is never instantiated
519 + // because nothing triggers model evaluation before the stream completes
520 + expect(errors).toEqual(['bam']);
521 + }
522 // Should still match the result when parsed
523 const result = await readResult(ssrStream);
524 const div = document.createElement('div');
packages/react-server/src/ReactFlightServer.js
+90 -49
@@ -649,9 +649,13 @@ function serializeThenable(
649 // We can no longer accept any resolved values
650 request.abortableTasks.delete(newTask);
651 newTask.status = ABORTED;
652 - const errorId: number = (request.fatalError: any);
653 - const model = stringify(serializeByValueID(errorId));
654 - emitModelChunk(request, newTask.id, model);
652 + if (enableHalt && request.type === PRERENDER) {
653 + request.pendingChunks--;
654 + } else {
655 + const errorId: number = (request.fatalError: any);
656 + const model = stringify(serializeByValueID(errorId));
657 + emitModelChunk(request, newTask.id, model);
658 + }
659 return newTask.id;
660 }
661 if (typeof thenable.status === 'string') {
@@ -1633,6 +1637,24 @@ function outlineTask(request: Request, task: Task): ReactJSONValue {
1637 return serializeLazyID(newTask.id);
1638 }
1639
1640 +function outlineHaltedTask(
1641 + request: Request,
1642 + task: Task,
1643 + allowLazy: boolean,
1644 +): ReactJSONValue {
1645 + // In the future if we track task state for resuming we'll maybe need to
1646 + // construnct an actual task here but since we're never going to retry it
1647 + // we just claim the id and serialize it according to the proper convention
1648 + const taskId = request.nextChunkId++;
1649 + if (allowLazy) {
1650 + // We're halting in a position that can handle a lazy reference
1651 + return serializeLazyID(taskId);
1652 + } else {
1653 + // We're halting in a position that needs a value reference
1654 + return serializeByValueID(taskId);
1655 + }
1656 +}
1657 +
1658 function renderElement(
1659 request: Request,
1660 task: Task,
@@ -2278,6 +2300,20 @@ function renderModel(
2300 ((model: any).$$typeof === REACT_ELEMENT_TYPE ||
2301 (model: any).$$typeof === REACT_LAZY_TYPE);
2302
2303 + if (request.status === ABORTING) {
2304 + task.status = ABORTED;
2305 + if (enableHalt && request.type === PRERENDER) {
2306 + // This will create a new task and refer to it in this slot
2307 + // the new task won't be retried because we are aborting
2308 + return outlineHaltedTask(request, task, wasReactNode);
2309 + }
2310 + const errorId = (request.fatalError: any);
2311 + if (wasReactNode) {
2312 + return serializeLazyID(errorId);
2313 + }
2314 + return serializeByValueID(errorId);
2315 + }
2316 +
2317 const x =
2318 thrownValue === SuspenseException
2319 ? // This is a special type of exception used for Suspense. For historical
@@ -2291,14 +2327,6 @@ function renderModel(
2327 if (typeof x === 'object' && x !== null) {
2328 // $FlowFixMe[method-unbinding]
2329 if (typeof x.then === 'function') {
2294 - if (request.status === ABORTING) {
2295 - task.status = ABORTED;
2296 - const errorId: number = (request.fatalError: any);
2297 - if (wasReactNode) {
2298 - return serializeLazyID(errorId);
2299 - }
2300 - return serializeByValueID(errorId);
2301 - }
2330 // Something suspended, we'll need to create a new task and resolve it later.
2331 const newTask = createTask(
2332 request,
@@ -2344,15 +2372,6 @@ function renderModel(
2372 }
2373 }
2374
2347 - if (request.status === ABORTING) {
2348 - task.status = ABORTED;
2349 - const errorId: number = (request.fatalError: any);
2350 - if (wasReactNode) {
2351 - return serializeLazyID(errorId);
2352 - }
2353 - return serializeByValueID(errorId);
2354 - }
2355 -
2375 // Restore the context. We assume that this will be restored by the inner
2376 // functions in case nothing throws so we don't use "finally" here.
2377 task.keyPath = prevKeyPath;
@@ -3820,6 +3839,22 @@ function retryTask(request: Request, task: Task): void {
3839 request.abortableTasks.delete(task);
3840 task.status = COMPLETED;
3841 } catch (thrownValue) {
3842 + if (request.status === ABORTING) {
3843 + request.abortableTasks.delete(task);
3844 + task.status = ABORTED;
3845 + if (enableHalt && request.type === PRERENDER) {
3846 + // When aborting a prerener with halt semantics we don't emit
3847 + // anything into the slot for a task that aborts, it remains unresolved
3848 + request.pendingChunks--;
3849 + } else {
3850 + // Otherwise we emit an error chunk into the task slot.
3851 + const errorId: number = (request.fatalError: any);
3852 + const model = stringify(serializeByValueID(errorId));
3853 + emitModelChunk(request, task.id, model);
3854 + }
3855 + return;
3856 + }
3857 +
3858 const x =
3859 thrownValue === SuspenseException
3860 ? // This is a special type of exception used for Suspense. For historical
@@ -3832,14 +3867,6 @@ function retryTask(request: Request, task: Task): void {
3867 if (typeof x === 'object' && x !== null) {
3868 // $FlowFixMe[method-unbinding]
3869 if (typeof x.then === 'function') {
3835 - if (request.status === ABORTING) {
3836 - request.abortableTasks.delete(task);
3837 - task.status = ABORTED;
3838 - const errorId: number = (request.fatalError: any);
3839 - const model = stringify(serializeByValueID(errorId));
3840 - emitModelChunk(request, task.id, model);
3841 - return;
3842 - }
3870 // Something suspended again, let's pick it back up later.
3871 task.status = PENDING;
3872 task.thenableState = getThenableStateAfterSuspending();
@@ -3856,15 +3883,6 @@ function retryTask(request: Request, task: Task): void {
3883 }
3884 }
3885
3859 - if (request.status === ABORTING) {
3860 - request.abortableTasks.delete(task);
3861 - task.status = ABORTED;
3862 - const errorId: number = (request.fatalError: any);
3863 - const model = stringify(serializeByValueID(errorId));
3864 - emitModelChunk(request, task.id, model);
3865 - return;
3866 - }
3867 -
3886 request.abortableTasks.delete(task);
3887 task.status = ERRORED;
3888 const digest = logRecoverableError(request, x, task);
@@ -3942,6 +3960,17 @@ function abortTask(task: Task, request: Request, errorId: number): void {
3960 request.completedErrorChunks.push(processedChunk);
3961 }
3962
3963 +function haltTask(task: Task, request: Request): void {
3964 + if (task.status === RENDERING) {
3965 + // this task will be halted by the render
3966 + return;
3967 + }
3968 + task.status = ABORTED;
3969 + // We don't actually emit anything for this task id because we are intentionally
3970 + // leaving the reference unfulfilled.
3971 + request.pendingChunks--;
3972 +}
3973 +
3974 function flushCompletedChunks(
3975 request: Request,
3976 destination: Destination,
@@ -4087,12 +4116,6 @@ export function abort(request: Request, reason: mixed): void {
4116 }
4117 const abortableTasks = request.abortableTasks;
4118 if (abortableTasks.size > 0) {
4090 - // We have tasks to abort. We'll emit one error row and then emit a reference
4091 - // to that row from every row that's still remaining if we are rendering. If we
4092 - // are prerendering (and halt semantics are enabled) we will refer to an error row
4093 - // but not actually emit it so the reciever can at that point rather than error.
4094 - const errorId = request.nextChunkId++;
4095 - request.fatalError = errorId;
4119 if (
4120 enablePostpone &&
4121 typeof reason === 'object' &&
@@ -4101,10 +4124,20 @@ export function abort(request: Request, reason: mixed): void {
4124 ) {
4125 const postponeInstance: Postpone = (reason: any);
4126 logPostpone(request, postponeInstance.message, null);
4104 - if (!enableHalt || request.type === PRERENDER) {
4105 - // When prerendering with halt semantics we omit the referred to postpone.
4127 + if (enableHalt && request.type === PRERENDER) {
4128 + // When prerendering with halt semantics we simply halt the task
4129 + // and leave the reference unfulfilled.
4130 + abortableTasks.forEach(task => haltTask(task, request));
4131 + abortableTasks.clear();
4132 + } else {
4133 + // When rendering we produce a shared postpone chunk and then
4134 + // fulfill each task with a reference to that chunk.
4135 + const errorId = request.nextChunkId++;
4136 + request.fatalError = errorId;
4137 request.pendingChunks++;
4138 emitPostponeChunk(request, errorId, postponeInstance);
4139 + abortableTasks.forEach(task => abortTask(task, request, errorId));
4140 + abortableTasks.clear();
4141 }
4142 } else {
4143 const error =
@@ -4120,14 +4153,22 @@ export function abort(request: Request, reason: mixed): void {
4153 )
4154 : reason;
4155 const digest = logRecoverableError(request, error, null);
4123 - if (!enableHalt || request.type === RENDER) {
4124 - // When prerendering with halt semantics we omit the referred to error.
4156 + if (enableHalt && request.type === PRERENDER) {
4157 + // When prerendering with halt semantics we simply halt the task
4158 + // and leave the reference unfulfilled.
4159 + abortableTasks.forEach(task => haltTask(task, request));
4160 + abortableTasks.clear();
4161 + } else {
4162 + // When rendering we produce a shared error chunk and then
4163 + // fulfill each task with a reference to that chunk.
4164 + const errorId = request.nextChunkId++;
4165 + request.fatalError = errorId;
4166 request.pendingChunks++;
4167 emitErrorChunk(request, errorId, digest, error);
4168 + abortableTasks.forEach(task => abortTask(task, request, errorId));
4169 + abortableTasks.clear();
4170 }
4171 }
4129 - abortableTasks.forEach(task => abortTask(task, request, errorId));
4130 - abortableTasks.clear();
4172 const onAllReady = request.onAllReady;
4173 onAllReady();
4174 }