@samitouri / QOS-React / commits / a451de014c

[Fizz] Allow aborting during render (#30488)

Currently if you abort a Fizz render during rendering the render will not complete correctly because there are inconsistencies with task counting. This change updates the abort implementation to allow you to abort from within a render itself. We already landed a similar change for Flight in #29764

Josh Story committed Jul 29, 2024 at 13:18 UTC a451de014ca71718aee924bb57d5b4a1d87e20f2
2 files changed +334 -17
packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
+250
@@ -8127,6 +8127,256 @@ describe('ReactDOMFizzServer', () => {
8127 expect(document.body.textContent).toBe('HelloWorld');
8128 });
8129
8130 + it('can abort synchronously during render', async () => {
8131 + function Sibling() {
8132 + return <p>sibling</p>;
8133 + }
8134 +
8135 + function App() {
8136 + return (
8137 + <div>
8138 + <Suspense fallback={<p>loading 1...</p>}>
8139 + <ComponentThatAborts />
8140 + <Sibling />
8141 + </Suspense>
8142 + <Suspense fallback={<p>loading 2...</p>}>
8143 + <Sibling />
8144 + </Suspense>
8145 + <div>
8146 + <Suspense fallback={<p>loading 3...</p>}>
8147 + <div>
8148 + <Sibling />
8149 + </div>
8150 + </Suspense>
8151 + </div>
8152 + </div>
8153 + );
8154 + }
8155 +
8156 + const abortRef = {current: null};
8157 + function ComponentThatAborts() {
8158 + abortRef.current();
8159 + return <p>hello world</p>;
8160 + }
8161 +
8162 + let finished = false;
8163 + await act(() => {
8164 + const {pipe, abort} = renderToPipeableStream(<App />);
8165 + abortRef.current = abort;
8166 + writable.on('finish', () => {
8167 + finished = true;
8168 + });
8169 + pipe(writable);
8170 + });
8171 +
8172 + assertConsoleErrorDev([
8173 + 'The render was aborted by the server without a reason.',
8174 + 'The render was aborted by the server without a reason.',
8175 + 'The render was aborted by the server without a reason.',
8176 + ]);
8177 +
8178 + expect(finished).toBe(true);
8179 + expect(getVisibleChildren(container)).toEqual(
8180 + <div>
8181 + <p>loading 1...</p>
8182 + <p>loading 2...</p>
8183 + <div>
8184 + <p>loading 3...</p>
8185 + </div>
8186 + </div>,
8187 + );
8188 + });
8189 +
8190 + it('can abort during render in a lazy initializer for a component', async () => {
8191 + function Sibling() {
8192 + return <p>sibling</p>;
8193 + }
8194 +
8195 + function App() {
8196 + return (
8197 + <div>
8198 + <Suspense fallback={<p>loading 1...</p>}>
8199 + <LazyAbort />
8200 + <Sibling />
8201 + </Suspense>
8202 + <Suspense fallback={<p>loading 2...</p>}>
8203 + <Sibling />
8204 + </Suspense>
8205 + <div>
8206 + <Suspense fallback={<p>loading 3...</p>}>
8207 + <div>
8208 + <Sibling />
8209 + </div>
8210 + </Suspense>
8211 + </div>
8212 + </div>
8213 + );
8214 + }
8215 +
8216 + const abortRef = {current: null};
8217 + const LazyAbort = React.lazy(() => {
8218 + abortRef.current();
8219 + return {
8220 + then(cb) {
8221 + cb({default: 'div'});
8222 + },
8223 + };
8224 + });
8225 +
8226 + let finished = false;
8227 + await act(() => {
8228 + const {pipe, abort} = renderToPipeableStream(<App />);
8229 + abortRef.current = abort;
8230 + writable.on('finish', () => {
8231 + finished = true;
8232 + });
8233 + pipe(writable);
8234 + });
8235 +
8236 + assertConsoleErrorDev([
8237 + 'The render was aborted by the server without a reason.',
8238 + 'The render was aborted by the server without a reason.',
8239 + 'The render was aborted by the server without a reason.',
8240 + ]);
8241 +
8242 + expect(finished).toBe(true);
8243 + expect(getVisibleChildren(container)).toEqual(
8244 + <div>
8245 + <p>loading 1...</p>
8246 + <p>loading 2...</p>
8247 + <div>
8248 + <p>loading 3...</p>
8249 + </div>
8250 + </div>,
8251 + );
8252 + });
8253 +
8254 + it('can abort during render in a lazy initializer for an element', async () => {
8255 + function Sibling() {
8256 + return <p>sibling</p>;
8257 + }
8258 +
8259 + function App() {
8260 + return (
8261 + <div>
8262 + <Suspense fallback={<p>loading 1...</p>}>
8263 + {lazyAbort}
8264 + <Sibling />
8265 + </Suspense>
8266 + <Suspense fallback={<p>loading 2...</p>}>
8267 + <Sibling />
8268 + </Suspense>
8269 + <div>
8270 + <Suspense fallback={<p>loading 3...</p>}>
8271 + <div>
8272 + <Sibling />
8273 + </div>
8274 + </Suspense>
8275 + </div>
8276 + </div>
8277 + );
8278 + }
8279 +
8280 + const abortRef = {current: null};
8281 + const lazyAbort = React.lazy(() => {
8282 + abortRef.current();
8283 + return {
8284 + then(cb) {
8285 + cb({default: 'hello world'});
8286 + },
8287 + };
8288 + });
8289 +
8290 + let finished = false;
8291 + await act(() => {
8292 + const {pipe, abort} = renderToPipeableStream(<App />);
8293 + abortRef.current = abort;
8294 + writable.on('finish', () => {
8295 + finished = true;
8296 + });
8297 + pipe(writable);
8298 + });
8299 +
8300 + assertConsoleErrorDev([
8301 + 'The render was aborted by the server without a reason.',
8302 + 'The render was aborted by the server without a reason.',
8303 + 'The render was aborted by the server without a reason.',
8304 + ]);
8305 +
8306 + expect(finished).toBe(true);
8307 + expect(getVisibleChildren(container)).toEqual(
8308 + <div>
8309 + <p>loading 1...</p>
8310 + <p>loading 2...</p>
8311 + <div>
8312 + <p>loading 3...</p>
8313 + </div>
8314 + </div>,
8315 + );
8316 + });
8317 +
8318 + it('can abort during a synchronous thenable resolution', async () => {
8319 + function Sibling() {
8320 + return <p>sibling</p>;
8321 + }
8322 +
8323 + function App() {
8324 + return (
8325 + <div>
8326 + <Suspense fallback={<p>loading 1...</p>}>
8327 + {thenable}
8328 + <Sibling />
8329 + </Suspense>
8330 + <Suspense fallback={<p>loading 2...</p>}>
8331 + <Sibling />
8332 + </Suspense>
8333 + <div>
8334 + <Suspense fallback={<p>loading 3...</p>}>
8335 + <div>
8336 + <Sibling />
8337 + </div>
8338 + </Suspense>
8339 + </div>
8340 + </div>
8341 + );
8342 + }
8343 +
8344 + const abortRef = {current: null};
8345 + const thenable = {
8346 + then(cb) {
8347 + abortRef.current();
8348 + cb(thenable.value);
8349 + },
8350 + };
8351 +
8352 + let finished = false;
8353 + await act(() => {
8354 + const {pipe, abort} = renderToPipeableStream(<App />);
8355 + abortRef.current = abort;
8356 + writable.on('finish', () => {
8357 + finished = true;
8358 + });
8359 + pipe(writable);
8360 + });
8361 +
8362 + assertConsoleErrorDev([
8363 + 'The render was aborted by the server without a reason.',
8364 + 'The render was aborted by the server without a reason.',
8365 + 'The render was aborted by the server without a reason.',
8366 + ]);
8367 +
8368 + expect(finished).toBe(true);
8369 + expect(getVisibleChildren(container)).toEqual(
8370 + <div>
8371 + <p>loading 1...</p>
8372 + <p>loading 2...</p>
8373 + <div>
8374 + <p>loading 3...</p>
8375 + </div>
8376 + </div>,
8377 + );
8378 + });
8379 +
8380 it('should warn for using generators as children props', async () => {
8381 function* getChildren() {
8382 yield <h1 key="1">Hello</h1>;
packages/react-server/src/ReactFizzServer.js
+84 -17
@@ -294,11 +294,12 @@ const FLUSHED = 2;
294 const ABORTED = 3;
295 const ERRORED = 4;
296 const POSTPONED = 5;
297 +const RENDERING = 6;
298
299 type Root = null;
300
301 type Segment = {
301 - status: 0 | 1 | 2 | 3 | 4 | 5,
302 + status: 0 | 1 | 2 | 3 | 4 | 5 | 6,
303 parentFlushed: boolean, // typically a segment will be flushed by its parent, except if its parent was already flushed
304 id: number, // starts as 0 and is lazily assigned if the parent flushes early
305 +index: number, // the index within the parent's chunks or 0 at the root
@@ -314,8 +315,9 @@ type Segment = {
315 };
316
317 const OPEN = 0;
317 -const CLOSING = 1;
318 -const CLOSED = 2;
318 +const ABORTING = 1;
319 +const CLOSING = 2;
320 +const CLOSED = 3;
321
322 export opaque type Request = {
323 destination: null | Destination,
@@ -324,7 +326,7 @@ export opaque type Request = {
326 +renderState: RenderState,
327 +rootFormatContext: FormatContext,
328 +progressiveChunkSize: number,
327 - status: 0 | 1 | 2,
329 + status: 0 | 1 | 2 | 3,
330 fatalError: mixed,
331 nextSegmentId: number,
332 allPendingTasks: number, // when it reaches zero, we can close the connection.
@@ -650,6 +652,8 @@ export function resumeRequest(
652 return request;
653 }
654
655 +const AbortSigil = {};
656 +
657 let currentRequest: null | Request = null;
658
659 export function resolveRequest(): null | Request {
@@ -1158,6 +1162,7 @@ function renderSuspenseBoundary(
1162
1163 task.blockedSegment = boundarySegment;
1164 task.keyPath = fallbackKeyPath;
1165 + boundarySegment.status = RENDERING;
1166 try {
1167 renderNode(request, task, fallback, -1);
1168 pushSegmentFinale(
@@ -1167,6 +1172,13 @@ function renderSuspenseBoundary(
1172 boundarySegment.textEmbedded,
1173 );
1174 boundarySegment.status = COMPLETED;
1175 + } catch (thrownValue: mixed) {
1176 + if (thrownValue === AbortSigil) {
1177 + boundarySegment.status = ABORTED;
1178 + } else {
1179 + boundarySegment.status = ERRORED;
1180 + }
1181 + throw thrownValue;
1182 } finally {
1183 task.blockedSegment = parentSegment;
1184 task.keyPath = prevKeyPath;
@@ -1211,6 +1223,7 @@ function renderSuspenseBoundary(
1223 task.hoistableState = newBoundary.contentState;
1224 task.blockedSegment = contentRootSegment;
1225 task.keyPath = keyPath;
1226 + contentRootSegment.status = RENDERING;
1227
1228 try {
1229 // We use the safe form because we don't handle suspending here. Only error handling.
@@ -1230,9 +1243,17 @@ function renderSuspenseBoundary(
1243 newBoundary.status = COMPLETED;
1244 return;
1245 }
1233 - } catch (error: mixed) {
1234 - contentRootSegment.status = ERRORED;
1246 + } catch (thrownValue: mixed) {
1247 newBoundary.status = CLIENT_RENDERED;
1248 + let error: mixed;
1249 + if (thrownValue === AbortSigil) {
1250 + contentRootSegment.status = ABORTED;
1251 + error = request.fatalError;
1252 + } else {
1253 + contentRootSegment.status = ERRORED;
1254 + error = thrownValue;
1255 + }
1256 +
1257 const thrownInfo = getThrownInfo(task.componentStack);
1258 let errorDigest;
1259 if (
@@ -1579,6 +1600,9 @@ function finishClassComponent(
1600 } else {
1601 nextChildren = instance.render();
1602 }
1603 + if (request.status === ABORTING) {
1604 + throw AbortSigil;
1605 + }
1606
1607 if (__DEV__) {
1608 if (instance.props !== props) {
@@ -1732,6 +1756,10 @@ function renderFunctionComponent(
1756 props,
1757 legacyContext,
1758 );
1759 + if (request.status === ABORTING) {
1760 + throw AbortSigil;
1761 + }
1762 +
1763 const hasId = checkDidRenderIdHook();
1764 const actionStateCount = getActionStateCount();
1765 const actionStateMatchingIndex = getActionStateMatchingIndex();
@@ -2047,6 +2075,9 @@ function renderLazyComponent(
2075 const init = lazyComponent._init;
2076 Component = init(payload);
2077 }
2078 + if (request.status === ABORTING) {
2079 + throw AbortSigil;
2080 + }
2081 const resolvedProps = resolveDefaultPropsOnNonClassComponent(
2082 Component,
2083 props,
@@ -2623,6 +2654,9 @@ function retryNode(request: Request, task: Task): void {
2654 const init = lazyNode._init;
2655 resolvedNode = init(payload);
2656 }
2657 + if (request.status === ABORTING) {
2658 + throw AbortSigil;
2659 + }
2660 // Now we render the resolved node
2661 renderNodeDestructive(request, task, resolvedNode, childIndex);
2662 return;
@@ -3738,6 +3772,11 @@ function abortTask(task: Task, request: Request, error: mixed): void {
3772 const boundary = task.blockedBoundary;
3773 const segment = task.blockedSegment;
3774 if (segment !== null) {
3775 + if (segment.status === RENDERING) {
3776 + // This is the a currently rendering Segment. The render itself will
3777 + // abort the task.
3778 + return;
3779 + }
3780 segment.status = ABORTED;
3781 }
3782
@@ -4032,6 +4071,10 @@ function retryRenderTask(
4071 // We completed this by other means before we had a chance to retry it.
4072 return;
4073 }
4074 +
4075 + // We track when a Segment is rendering so we can handle aborts while rendering
4076 + segment.status = RENDERING;
4077 +
4078 // We restore the context to what it was when we suspended.
4079 // We don't restore it after we leave because it's likely that we'll end up
4080 // needing a very similar context soon again.
@@ -4080,9 +4123,10 @@ function retryRenderTask(
4123 // $FlowFixMe[method-unbinding]
4124 if (typeof x.then === 'function') {
4125 // Something suspended again, let's pick it back up later.
4126 + segment.status = PENDING;
4127 + task.thenableState = getThenableStateAfterSuspending();
4128 const ping = task.ping;
4129 x.then(ping, ping);
4085 - task.thenableState = getThenableStateAfterSuspending();
4130 return;
4131 } else if (
4132 enablePostpone &&
@@ -4111,14 +4155,26 @@ function retryRenderTask(
4155
4156 const errorInfo = getThrownInfo(task.componentStack);
4157 task.abortSet.delete(task);
4114 - segment.status = ERRORED;
4115 - erroredTask(
4116 - request,
4117 - task.blockedBoundary,
4118 - x,
4119 - errorInfo,
4120 - __DEV__ && enableOwnerStacks ? task.debugTask : null,
4121 - );
4158 +
4159 + if (x === AbortSigil) {
4160 + segment.status = ABORTED;
4161 + erroredTask(
4162 + request,
4163 + task.blockedBoundary,
4164 + request.fatalError,
4165 + errorInfo,
4166 + __DEV__ && enableOwnerStacks ? task.debugTask : null,
4167 + );
4168 + } else {
4169 + segment.status = ERRORED;
4170 + erroredTask(
4171 + request,
4172 + task.blockedBoundary,
4173 + x,
4174 + errorInfo,
4175 + __DEV__ && enableOwnerStacks ? task.debugTask : null,
4176 + );
4177 + }
4178 return;
4179 } finally {
4180 if (__DEV__) {
@@ -4192,7 +4248,7 @@ function retryReplayTask(request: Request, task: ReplayTask): void {
4248 erroredReplay(
4249 request,
4250 task.blockedBoundary,
4195 - x,
4251 + x === AbortSigil ? request.fatalError : x,
4252 errorInfo,
4253 task.replay.nodes,
4254 task.replay.slots,
@@ -4725,6 +4781,7 @@ function flushCompletedQueues(
4781 }
4782 }
4783 // We're done.
4784 + request.status = CLOSED;
4785 close(destination);
4786 // We need to stop flowing now because we do not want any async contexts which might call
4787 // float methods to initiate any flushes after this point
@@ -4846,13 +4903,23 @@ export function stopFlowing(request: Request): void {
4903
4904 // This is called to early terminate a request. It puts all pending boundaries in client rendered state.
4905 export function abort(request: Request, reason: mixed): void {
4906 + if (request.status === OPEN) {
4907 + request.status = ABORTING;
4908 + }
4909 try {
4910 const abortableTasks = request.abortableTasks;
4911 if (abortableTasks.size > 0) {
4912 const error =
4913 reason === undefined
4914 ? new Error('The render was aborted by the server without a reason.')
4855 - : reason;
4915 + : typeof reason === 'object' &&
4916 + reason !== null &&
4917 + typeof reason.then === 'function'
4918 + ? new Error('The render was aborted by the server with a promise.')
4919 + : reason;
4920 + // This error isn't necessarily fatal in this case but we need to stash it
4921 + // so we can use it to abort any pending work
4922 + request.fatalError = error;
4923 abortableTasks.forEach(task => abortTask(task, request, error));
4924 abortableTasks.clear();
4925 }