Skip to content

Commit 2771d29

Browse files
committed
[Fizz][Static] when aborting a prerender halt unfinished boundaries instead of erroring
When we introduces prerendering for flight we modeled an abort of a flight prerender as having unfinished rows. This is similar to how postpone was already implemented when you postponed from "within" a prerender using React.unstable_postpone. However when aborting with a postponed instance every boundary would be eagerly marked for client rendering which is more akin to prerendering and then resuming with an aborted signal. The insight with the flight work was that it's not so much the postpone that describes the intended semantics but the abort combined with a prerender. So like in flight when you abort a prerender and enableHalt is enabled boundaries and the shell won't error for any reason. Fizz will still call onPostpone and onError according to the abort reason but the consuemr of the prerender should expect to resume it before trying to use it.
1 parent 92d26c8 commit 2771d29

File tree

5 files changed

+396
-11
lines changed

5 files changed

+396
-11
lines changed

packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7746,6 +7746,112 @@ describe('ReactDOMFizzServer', () => {
77467746
);
77477747
});
77487748

7749+
// @gate enableHalt
7750+
it('can resume a prerender that was aborted', async () => {
7751+
const promise = new Promise(r => {});
7752+
7753+
let prerendering = true;
7754+
7755+
function Wait() {
7756+
if (prerendering) {
7757+
return React.use(promise);
7758+
} else {
7759+
return 'Hello';
7760+
}
7761+
}
7762+
7763+
function App() {
7764+
return (
7765+
<div>
7766+
<Suspense fallback="Loading...">
7767+
<p>
7768+
<span>
7769+
<Suspense fallback="Loading again...">
7770+
<Wait />
7771+
</Suspense>
7772+
</span>
7773+
</p>
7774+
<p>
7775+
<span>
7776+
<Suspense fallback="Loading again too...">
7777+
<Wait />
7778+
</Suspense>
7779+
</span>
7780+
</p>
7781+
</Suspense>
7782+
</div>
7783+
);
7784+
}
7785+
7786+
const controller = new AbortController();
7787+
const signal = controller.signal;
7788+
7789+
const errors = [];
7790+
function onError(error) {
7791+
errors.push(error);
7792+
}
7793+
let pendingPrerender;
7794+
await act(() => {
7795+
pendingPrerender = ReactDOMFizzStatic.prerenderToNodeStream(<App />, {
7796+
signal,
7797+
onError,
7798+
});
7799+
});
7800+
controller.abort('boom');
7801+
7802+
const prerendered = await pendingPrerender;
7803+
7804+
expect(errors).toEqual(['boom', 'boom']);
7805+
7806+
const preludeWritable = new Stream.PassThrough();
7807+
preludeWritable.setEncoding('utf8');
7808+
preludeWritable.on('data', chunk => {
7809+
writable.write(chunk);
7810+
});
7811+
7812+
await act(() => {
7813+
prerendered.prelude.pipe(preludeWritable);
7814+
});
7815+
7816+
expect(getVisibleChildren(container)).toEqual(
7817+
<div>
7818+
<p>
7819+
<span>Loading again...</span>
7820+
</p>
7821+
<p>
7822+
<span>Loading again too...</span>
7823+
</p>
7824+
</div>,
7825+
);
7826+
7827+
prerendering = false;
7828+
7829+
errors.length = 0;
7830+
const resumed = await ReactDOMFizzServer.resumeToPipeableStream(
7831+
<App />,
7832+
JSON.parse(JSON.stringify(prerendered.postponed)),
7833+
{
7834+
onError,
7835+
},
7836+
);
7837+
7838+
await act(() => {
7839+
resumed.pipe(writable);
7840+
});
7841+
7842+
expect(errors).toEqual([]);
7843+
expect(getVisibleChildren(container)).toEqual(
7844+
<div>
7845+
<p>
7846+
<span>Hello</span>
7847+
</p>
7848+
<p>
7849+
<span>Hello</span>
7850+
</p>
7851+
</div>,
7852+
);
7853+
});
7854+
77497855
// @gate enablePostpone
77507856
it('does not call onError when you abort with a postpone instance during resume', async () => {
77517857
let prerendering = true;

packages/react-dom/src/__tests__/ReactDOMFizzStatic-test.js

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -454,4 +454,56 @@ describe('ReactDOMFizzStatic', () => {
454454
});
455455
expect(getVisibleChildren(container)).toEqual(undefined);
456456
});
457+
458+
// @enableHalt
459+
it('will halt a prerender when aborting with an error during a render', async () => {
460+
const controller = new AbortController();
461+
function App() {
462+
controller.abort('sync');
463+
return <div>hello world</div>;
464+
}
465+
466+
const errors = [];
467+
const result = await ReactDOMFizzStatic.prerenderToNodeStream(<App />, {
468+
signal: controller.signal,
469+
onError(error) {
470+
errors.push(error);
471+
},
472+
});
473+
await act(async () => {
474+
result.prelude.pipe(writable);
475+
});
476+
expect(errors).toEqual(['sync']);
477+
expect(getVisibleChildren(container)).toEqual(undefined);
478+
});
479+
480+
// @enableHalt
481+
it('will halt a prerender when aborting with an error in a microtask', async () => {
482+
const errors = [];
483+
484+
const controller = new AbortController();
485+
function App() {
486+
React.use(
487+
new Promise(() => {
488+
Promise.resolve().then(() => {
489+
controller.abort('async');
490+
});
491+
}),
492+
);
493+
return <div>hello world</div>;
494+
}
495+
496+
errors.length = 0;
497+
const result = await ReactDOMFizzStatic.prerenderToNodeStream(<App />, {
498+
signal: controller.signal,
499+
onError(error) {
500+
errors.push(error);
501+
},
502+
});
503+
await act(async () => {
504+
result.prelude.pipe(writable);
505+
});
506+
expect(errors).toEqual(['async']);
507+
expect(getVisibleChildren(container)).toEqual(undefined);
508+
});
457509
});

packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js

Lines changed: 78 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -306,8 +306,8 @@ describe('ReactDOMFizzStaticBrowser', () => {
306306
expect(errors).toEqual(['The operation was aborted.']);
307307
});
308308

309-
// @gate experimental
310-
it('should reject if aborting before the shell is complete', async () => {
309+
// @gate !enableHalt
310+
it('should reject if aborting before the shell is complete and enableHalt is disabled', async () => {
311311
const errors = [];
312312
const controller = new AbortController();
313313
const promise = serverAct(() =>
@@ -339,6 +339,42 @@ describe('ReactDOMFizzStaticBrowser', () => {
339339
expect(errors).toEqual(['aborted for reasons']);
340340
});
341341

342+
// @gate enableHalt
343+
it('should resolve an empty prelude if aborting before the shell is complete', async () => {
344+
const errors = [];
345+
const controller = new AbortController();
346+
const promise = serverAct(() =>
347+
ReactDOMFizzStatic.prerender(
348+
<div>
349+
<InfiniteSuspend />
350+
</div>,
351+
{
352+
signal: controller.signal,
353+
onError(x) {
354+
errors.push(x.message);
355+
},
356+
},
357+
),
358+
);
359+
360+
await jest.runAllTimers();
361+
362+
const theReason = new Error('aborted for reasons');
363+
controller.abort(theReason);
364+
365+
let rejected = false;
366+
let prelude;
367+
try {
368+
({prelude} = await promise);
369+
} catch (error) {
370+
rejected = true;
371+
}
372+
expect(rejected).toBe(false);
373+
expect(errors).toEqual(['aborted for reasons']);
374+
const content = await readContent(prelude);
375+
expect(content).toBe('');
376+
});
377+
342378
// @gate experimental
343379
it('should be able to abort before something suspends', async () => {
344380
const errors = [];
@@ -375,8 +411,8 @@ describe('ReactDOMFizzStaticBrowser', () => {
375411
expect(errors).toEqual(['The operation was aborted.']);
376412
});
377413

378-
// @gate experimental
379-
it('should reject if passing an already aborted signal', async () => {
414+
// @gate !enableHalt
415+
it('should reject if passing an already aborted signal and enableHalt is disabled', async () => {
380416
const errors = [];
381417
const controller = new AbortController();
382418
const theReason = new Error('aborted for reasons');
@@ -410,6 +446,44 @@ describe('ReactDOMFizzStaticBrowser', () => {
410446
expect(errors).toEqual(['aborted for reasons']);
411447
});
412448

449+
// @gate enableHalt
450+
it('should resolve an empty prelude if passing an already aborted signal', async () => {
451+
const errors = [];
452+
const controller = new AbortController();
453+
const theReason = new Error('aborted for reasons');
454+
controller.abort(theReason);
455+
456+
const promise = serverAct(() =>
457+
ReactDOMFizzStatic.prerender(
458+
<div>
459+
<Suspense fallback={<div>Loading</div>}>
460+
<InfiniteSuspend />
461+
</Suspense>
462+
</div>,
463+
{
464+
signal: controller.signal,
465+
onError(x) {
466+
errors.push(x.message);
467+
},
468+
},
469+
),
470+
);
471+
472+
// Technically we could still continue rendering the shell but currently the
473+
// semantics mean that we also abort any pending CPU work.
474+
let didThrow = false;
475+
let prelude;
476+
try {
477+
({prelude} = await promise);
478+
} catch (error) {
479+
didThrow = true;
480+
}
481+
expect(didThrow).toBe(false);
482+
expect(errors).toEqual(['aborted for reasons']);
483+
const content = await readContent(prelude);
484+
expect(content).toBe('');
485+
});
486+
413487
// @gate experimental
414488
it('supports custom abort reasons with a string', async () => {
415489
const promise = new Promise(r => {});

packages/react-dom/src/__tests__/ReactDOMFizzStaticNode-test.js

Lines changed: 75 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -211,8 +211,8 @@ describe('ReactDOMFizzStaticNode', () => {
211211
expect(errors).toEqual(['This operation was aborted']);
212212
});
213213

214-
// @gate experimental
215-
it('should reject if aborting before the shell is complete', async () => {
214+
// @gate !enableHalt
215+
it('should reject if aborting before the shell is complete and enableHalt is disabled', async () => {
216216
const errors = [];
217217
const controller = new AbortController();
218218
const promise = ReactDOMFizzStatic.prerenderToNodeStream(
@@ -242,6 +242,40 @@ describe('ReactDOMFizzStaticNode', () => {
242242
expect(errors).toEqual(['aborted for reasons']);
243243
});
244244

245+
// @gate enableHalt
246+
it('should resolve an empty shell if aborting before the shell is complete', async () => {
247+
const errors = [];
248+
const controller = new AbortController();
249+
const promise = ReactDOMFizzStatic.prerenderToNodeStream(
250+
<div>
251+
<InfiniteSuspend />
252+
</div>,
253+
{
254+
signal: controller.signal,
255+
onError(x) {
256+
errors.push(x.message);
257+
},
258+
},
259+
);
260+
261+
await jest.runAllTimers();
262+
263+
const theReason = new Error('aborted for reasons');
264+
controller.abort(theReason);
265+
266+
let didThrow = false;
267+
let prelude;
268+
try {
269+
({prelude} = await promise);
270+
} catch (error) {
271+
didThrow = true;
272+
}
273+
expect(didThrow).toBe(false);
274+
expect(errors).toEqual(['aborted for reasons']);
275+
const content = await readContent(prelude);
276+
expect(content).toBe('');
277+
});
278+
245279
// @gate experimental
246280
it('should be able to abort before something suspends', async () => {
247281
const errors = [];
@@ -276,8 +310,8 @@ describe('ReactDOMFizzStaticNode', () => {
276310
expect(errors).toEqual(['This operation was aborted']);
277311
});
278312

279-
// @gate experimental
280-
it('should reject if passing an already aborted signal', async () => {
313+
// @gate !enableHalt
314+
it('should reject if passing an already aborted signal and enableHalt is disabled', async () => {
281315
const errors = [];
282316
const controller = new AbortController();
283317
const theReason = new Error('aborted for reasons');
@@ -309,6 +343,43 @@ describe('ReactDOMFizzStaticNode', () => {
309343
expect(errors).toEqual(['aborted for reasons']);
310344
});
311345

346+
// @gate enableHalt
347+
it('should resolve with an empty prelude if passing an already aborted signal', async () => {
348+
const errors = [];
349+
const controller = new AbortController();
350+
const theReason = new Error('aborted for reasons');
351+
controller.abort(theReason);
352+
353+
const promise = ReactDOMFizzStatic.prerenderToNodeStream(
354+
<div>
355+
<Suspense fallback={<div>Loading</div>}>
356+
<InfiniteSuspend />
357+
</Suspense>
358+
</div>,
359+
{
360+
signal: controller.signal,
361+
onError(x) {
362+
errors.push(x.message);
363+
},
364+
},
365+
);
366+
367+
// Technically we could still continue rendering the shell but currently the
368+
// semantics mean that we also abort any pending CPU work.
369+
370+
let didThrow = false;
371+
let prelude;
372+
try {
373+
({prelude} = await promise);
374+
} catch (error) {
375+
didThrow = true;
376+
}
377+
expect(didThrow).toBe(false);
378+
expect(errors).toEqual(['aborted for reasons']);
379+
const content = await readContent(prelude);
380+
expect(content).toBe('');
381+
});
382+
312383
// @gate experimental
313384
it('supports custom abort reasons with a string', async () => {
314385
const promise = new Promise(r => {});

0 commit comments

Comments
 (0)