@samitouri / QOS-React-1 / commits / 1219d57fc9

[Fizz] Support aborting with Postpone (#28183)

Semantically if you make your reason for aborting a Postpone instance the render should not hit the error pathways but should instead follow the postpone pathways. It's awkward today to actually get your hands on a Postpone instance because you have to catch the throw from postpone and then pass that into `abort()` or `AbortController.abort()` (depending on the renderer API you are using) This change makes it so that in most circumstances if you abort with a postpone the `onPostpone` handler will be called and the Suspense boundaries still pending will be put into client render mode with the appropriate postpone digest to avoid trigger recoverable error pathways on the client. Similar to postponing in the shell during a resume or render however if you abort before the shell is complete in a resume or render we will fatally error. The fatal error is contextualized by React to avoid passing the postpone object itself to the `onError` and related options.

Josh Story committed Feb 1, 2024 at 07:14 UTC 1219d57fc9fcbf44c873c0b10e5acbd31f613c15
3 files changed +494 -5
packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
+447
@@ -7498,4 +7498,451 @@ describe('ReactDOMFizzServer', () => {
7498 </div>,
7499 );
7500 });
7501 +
7502 + // @gate enablePostpone
7503 + it('does not call onError when you abort with a postpone instance during prerender', async () => {
7504 + const promise = new Promise(r => {});
7505 +
7506 + function Wait() {
7507 + return React.use(promise);
7508 + }
7509 +
7510 + function App() {
7511 + return (
7512 + <div>
7513 + <Suspense fallback="Loading...">
7514 + <p>
7515 + <span>
7516 + <Suspense fallback="Loading again...">
7517 + <Wait />
7518 + </Suspense>
7519 + </span>
7520 + </p>
7521 + <p>
7522 + <span>
7523 + <Suspense fallback="Loading again too...">
7524 + <Wait />
7525 + </Suspense>
7526 + </span>
7527 + </p>
7528 + </Suspense>
7529 + </div>
7530 + );
7531 + }
7532 +
7533 + let postponeInstance;
7534 + try {
7535 + React.unstable_postpone('manufactured');
7536 + } catch (p) {
7537 + postponeInstance = p;
7538 + }
7539 +
7540 + const controller = new AbortController();
7541 + const signal = controller.signal;
7542 +
7543 + const errors = [];
7544 + function onError(error) {
7545 + errors.push(error);
7546 + }
7547 + const postpones = [];
7548 + function onPostpone(reason) {
7549 + postpones.push(reason);
7550 + }
7551 + let pendingPrerender;
7552 + await act(() => {
7553 + pendingPrerender = ReactDOMFizzStatic.prerenderToNodeStream(<App />, {
7554 + signal,
7555 + onError,
7556 + onPostpone,
7557 + });
7558 + });
7559 + controller.abort(postponeInstance);
7560 +
7561 + const prerendered = await pendingPrerender;
7562 +
7563 + expect(prerendered.postponed).toBe(null);
7564 + expect(errors).toEqual([]);
7565 + expect(postpones).toEqual(['manufactured', 'manufactured']);
7566 +
7567 + await act(() => {
7568 + prerendered.prelude.pipe(writable);
7569 + });
7570 +
7571 + expect(getVisibleChildren(container)).toEqual(
7572 + <div>
7573 + <p>
7574 + <span>Loading again...</span>
7575 + </p>
7576 + <p>
7577 + <span>Loading again too...</span>
7578 + </p>
7579 + </div>,
7580 + );
7581 + });
7582 +
7583 + // @gate enablePostpone
7584 + it('does not call onError when you abort with a postpone instance during resume', async () => {
7585 + let prerendering = true;
7586 + const promise = new Promise(r => {});
7587 +
7588 + function Wait() {
7589 + return React.use(promise);
7590 + }
7591 + function Postpone() {
7592 + if (prerendering) {
7593 + React.unstable_postpone();
7594 + }
7595 + return (
7596 + <span>
7597 + <Suspense fallback="Loading again...">
7598 + <Wait />
7599 + </Suspense>
7600 + </span>
7601 + );
7602 + }
7603 +
7604 + function App() {
7605 + return (
7606 + <div>
7607 + <Suspense fallback="Loading...">
7608 + <p>
7609 + <Postpone />
7610 + </p>
7611 + <p>
7612 + <Postpone />
7613 + </p>
7614 + </Suspense>
7615 + </div>
7616 + );
7617 + }
7618 +
7619 + const prerendered = await ReactDOMFizzStatic.prerenderToNodeStream(<App />);
7620 + expect(prerendered.postponed).not.toBe(null);
7621 +
7622 + prerendering = false;
7623 +
7624 + // Create a separate stream so it doesn't close the writable. I.e. simple concat.
7625 + const preludeWritable = new Stream.PassThrough();
7626 + preludeWritable.setEncoding('utf8');
7627 + preludeWritable.on('data', chunk => {
7628 + writable.write(chunk);
7629 + });
7630 +
7631 + await act(() => {
7632 + prerendered.prelude.pipe(preludeWritable);
7633 + });
7634 +
7635 + expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
7636 +
7637 + let postponeInstance;
7638 + try {
7639 + React.unstable_postpone('manufactured');
7640 + } catch (p) {
7641 + postponeInstance = p;
7642 + }
7643 +
7644 + const errors = [];
7645 + function onError(error) {
7646 + errors.push(error);
7647 + }
7648 + const postpones = [];
7649 + function onPostpone(reason) {
7650 + postpones.push(reason);
7651 + }
7652 +
7653 + prerendering = false;
7654 +
7655 + const resumed = await ReactDOMFizzServer.resumeToPipeableStream(
7656 + <App />,
7657 + JSON.parse(JSON.stringify(prerendered.postponed)),
7658 + {
7659 + onError,
7660 + onPostpone,
7661 + },
7662 + );
7663 +
7664 + await act(() => {
7665 + resumed.pipe(writable);
7666 + });
7667 + await act(() => {
7668 + resumed.abort(postponeInstance);
7669 + });
7670 +
7671 + expect(getVisibleChildren(container)).toEqual(
7672 + <div>
7673 + <p>
7674 + <span>Loading again...</span>
7675 + </p>
7676 + <p>
7677 + <span>Loading again...</span>
7678 + </p>
7679 + </div>,
7680 + );
7681 +
7682 + expect(errors).toEqual([]);
7683 + expect(postpones).toEqual(['manufactured', 'manufactured']);
7684 + });
7685 +
7686 + // @gate enablePostpone
7687 + it('does not call onError when you abort with a postpone instance during a render', async () => {
7688 + const promise = new Promise(r => {});
7689 +
7690 + function Wait() {
7691 + return React.use(promise);
7692 + }
7693 +
7694 + function App() {
7695 + return (
7696 + <div>
7697 + <Suspense fallback="Loading...">
7698 + <p>
7699 + <span>
7700 + <Suspense fallback="Loading again...">
7701 + <Wait />
7702 + </Suspense>
7703 + </span>
7704 + </p>
7705 + <p>
7706 + <span>
7707 + <Suspense fallback="Loading again...">
7708 + <Wait />
7709 + </Suspense>
7710 + </span>
7711 + </p>
7712 + </Suspense>
7713 + </div>
7714 + );
7715 + }
7716 +
7717 + const errors = [];
7718 + function onError(error) {
7719 + errors.push(error);
7720 + }
7721 + const postpones = [];
7722 + function onPostpone(reason) {
7723 + postpones.push(reason);
7724 + }
7725 + const result = await renderToPipeableStream(<App />, {onError, onPostpone});
7726 + await act(() => {
7727 + result.pipe(writable);
7728 + });
7729 +
7730 + expect(getVisibleChildren(container)).toEqual(
7731 + <div>
7732 + <p>
7733 + <span>Loading again...</span>
7734 + </p>
7735 + <p>
7736 + <span>Loading again...</span>
7737 + </p>
7738 + </div>,
7739 + );
7740 +
7741 + let postponeInstance;
7742 + try {
7743 + React.unstable_postpone('manufactured');
7744 + } catch (p) {
7745 + postponeInstance = p;
7746 + }
7747 + await act(() => {
7748 + result.abort(postponeInstance);
7749 + });
7750 +
7751 + expect(getVisibleChildren(container)).toEqual(
7752 + <div>
7753 + <p>
7754 + <span>Loading again...</span>
7755 + </p>
7756 + <p>
7757 + <span>Loading again...</span>
7758 + </p>
7759 + </div>,
7760 + );
7761 +
7762 + expect(errors).toEqual([]);
7763 + expect(postpones).toEqual(['manufactured', 'manufactured']);
7764 + });
7765 +
7766 + // @gate enablePostpone
7767 + it('fatally errors if you abort with a postpone in the shell during resume', async () => {
7768 + let prerendering = true;
7769 + const promise = new Promise(r => {});
7770 +
7771 + function Wait() {
7772 + return React.use(promise);
7773 + }
7774 + function Postpone() {
7775 + if (prerendering) {
7776 + React.unstable_postpone();
7777 + }
7778 + return (
7779 + <span>
7780 + <Suspense fallback="Loading again...">
7781 + <Wait />
7782 + </Suspense>
7783 + </span>
7784 + );
7785 + }
7786 +
7787 + function PostponeInShell() {
7788 + if (prerendering) {
7789 + React.unstable_postpone();
7790 + }
7791 + return <span>in shell</span>;
7792 + }
7793 +
7794 + function App() {
7795 + return (
7796 + <div>
7797 + <PostponeInShell />
7798 + <Suspense fallback="Loading...">
7799 + <p>
7800 + <Postpone />
7801 + </p>
7802 + <p>
7803 + <Postpone />
7804 + </p>
7805 + </Suspense>
7806 + </div>
7807 + );
7808 + }
7809 +
7810 + const prerendered = await ReactDOMFizzStatic.prerenderToNodeStream(<App />);
7811 + expect(prerendered.postponed).not.toBe(null);
7812 +
7813 + prerendering = false;
7814 +
7815 + // Create a separate stream so it doesn't close the writable. I.e. simple concat.
7816 + const preludeWritable = new Stream.PassThrough();
7817 + preludeWritable.setEncoding('utf8');
7818 + preludeWritable.on('data', chunk => {
7819 + writable.write(chunk);
7820 + });
7821 +
7822 + await act(() => {
7823 + prerendered.prelude.pipe(preludeWritable);
7824 + });
7825 +
7826 + expect(getVisibleChildren(container)).toEqual(undefined);
7827 +
7828 + let postponeInstance;
7829 + try {
7830 + React.unstable_postpone('manufactured');
7831 + } catch (p) {
7832 + postponeInstance = p;
7833 + }
7834 +
7835 + const errors = [];
7836 + function onError(error) {
7837 + errors.push(error);
7838 + }
7839 + const shellErrors = [];
7840 + function onShellError(error) {
7841 + shellErrors.push(error);
7842 + }
7843 + const postpones = [];
7844 + function onPostpone(reason) {
7845 + postpones.push(reason);
7846 + }
7847 +
7848 + prerendering = false;
7849 +
7850 + const resumed = await ReactDOMFizzServer.resumeToPipeableStream(
7851 + <App />,
7852 + JSON.parse(JSON.stringify(prerendered.postponed)),
7853 + {
7854 + onError,
7855 + onShellError,
7856 + onPostpone,
7857 + },
7858 + );
7859 + await act(() => {
7860 + resumed.abort(postponeInstance);
7861 + });
7862 + expect(errors).toEqual([
7863 + new Error(
7864 + 'The render was aborted with postpone when the shell is incomplete. Reason: manufactured',
7865 + ),
7866 + ]);
7867 + expect(shellErrors).toEqual([
7868 + new Error(
7869 + 'The render was aborted with postpone when the shell is incomplete. Reason: manufactured',
7870 + ),
7871 + ]);
7872 + expect(postpones).toEqual([]);
7873 + });
7874 +
7875 + // @gate enablePostpone
7876 + it('fatally errors if you abort with a postpone in the shell during render', async () => {
7877 + const promise = new Promise(r => {});
7878 +
7879 + function Wait() {
7880 + return React.use(promise);
7881 + }
7882 +
7883 + function App() {
7884 + return (
7885 + <div>
7886 + <Suspense fallback="Loading...">
7887 + <p>
7888 + <span>
7889 + <Suspense fallback="Loading again...">
7890 + <Wait />
7891 + </Suspense>
7892 + </span>
7893 + </p>
7894 + <p>
7895 + <span>
7896 + <Suspense fallback="Loading again...">
7897 + <Wait />
7898 + </Suspense>
7899 + </span>
7900 + </p>
7901 + </Suspense>
7902 + </div>
7903 + );
7904 + }
7905 +
7906 + const errors = [];
7907 + function onError(error) {
7908 + errors.push(error);
7909 + }
7910 + const shellErrors = [];
7911 + function onShellError(error) {
7912 + shellErrors.push(error);
7913 + }
7914 + const postpones = [];
7915 + function onPostpone(reason) {
7916 + postpones.push(reason);
7917 + }
7918 + const result = await renderToPipeableStream(<App />, {
7919 + onError,
7920 + onShellError,
7921 + onPostpone,
7922 + });
7923 +
7924 + let postponeInstance;
7925 + try {
7926 + React.unstable_postpone('manufactured');
7927 + } catch (p) {
7928 + postponeInstance = p;
7929 + }
7930 + await act(() => {
7931 + result.abort(postponeInstance);
7932 + });
7933 +
7934 + expect(getVisibleChildren(container)).toEqual(undefined);
7935 +
7936 + expect(errors).toEqual([
7937 + new Error(
7938 + 'The render was aborted with postpone when the shell is incomplete. Reason: manufactured',
7939 + ),
7940 + ]);
7941 + expect(shellErrors).toEqual([
7942 + new Error(
7943 + 'The render was aborted with postpone when the shell is incomplete. Reason: manufactured',
7944 + ),
7945 + ]);
7946 + expect(postpones).toEqual([]);
7947 + });
7948 });
packages/react-server/src/ReactFizzServer.js
+45 -4
@@ -3129,8 +3129,23 @@ function abortTask(task: Task, request: Request, error: mixed): void {
3129 if (replay === null) {
3130 // We didn't complete the root so we have nothing to show. We can close
3131 // the request;
3132 - logRecoverableError(request, error, errorInfo);
3133 - fatalError(request, error);
3132 + if (
3133 + enablePostpone &&
3134 + typeof error === 'object' &&
3135 + error !== null &&
3136 + error.$$typeof === REACT_POSTPONE_TYPE
3137 + ) {
3138 + const postponeInstance: Postpone = (error: any);
3139 + const fatal = new Error(
3140 + 'The render was aborted with postpone when the shell is incomplete. Reason: ' +
3141 + postponeInstance.message,
3142 + );
3143 + logRecoverableError(request, fatal, errorInfo);
3144 + fatalError(request, fatal);
3145 + } else {
3146 + logRecoverableError(request, error, errorInfo);
3147 + fatalError(request, error);
3148 + }
3149 return;
3150 } else {
3151 // If the shell aborts during a replay, that's not a fatal error. Instead
@@ -3138,7 +3153,20 @@ function abortTask(task: Task, request: Request, error: mixed): void {
3153 // the ReplaySet.
3154 replay.pendingTasks--;
3155 if (replay.pendingTasks === 0 && replay.nodes.length > 0) {
3141 - const errorDigest = logRecoverableError(request, error, errorInfo);
3156 + let errorDigest;
3157 + if (
3158 + enablePostpone &&
3159 + typeof error === 'object' &&
3160 + error !== null &&
3161 + error.$$typeof === REACT_POSTPONE_TYPE
3162 + ) {
3163 + const postponeInstance: Postpone = (error: any);
3164 + logPostpone(request, postponeInstance.message, errorInfo);
3165 + // TODO: Figure out a better signal than a magic digest value.
3166 + errorDigest = 'POSTPONE';
3167 + } else {
3168 + errorDigest = logRecoverableError(request, error, errorInfo);
3169 + }
3170 abortRemainingReplayNodes(
3171 request,
3172 null,
@@ -3162,7 +3190,20 @@ function abortTask(task: Task, request: Request, error: mixed): void {
3190 // We construct an errorInfo from the boundary's componentStack so the error in dev will indicate which
3191 // boundary the message is referring to
3192 const errorInfo = getThrownInfo(request, task.componentStack);
3165 - const errorDigest = logRecoverableError(request, error, errorInfo);
3193 + let errorDigest;
3194 + if (
3195 + enablePostpone &&
3196 + typeof error === 'object' &&
3197 + error !== null &&
3198 + error.$$typeof === REACT_POSTPONE_TYPE
3199 + ) {
3200 + const postponeInstance: Postpone = (error: any);
3201 + logPostpone(request, postponeInstance.message, errorInfo);
3202 + // TODO: Figure out a better signal than a magic digest value.
3203 + errorDigest = 'POSTPONE';
3204 + } else {
3205 + errorDigest = logRecoverableError(request, error, errorInfo);
3206 + }
3207 let errorMessage = error;
3208 if (__DEV__) {
3209 const errorPrefix =
scripts/error-codes/codes.json
+2 -1
@@ -485,5 +485,6 @@
485 "497": "Only objects or functions can be passed to taintObjectReference.",
486 "498": "Only plain objects, and a few built-ins, can be passed to Client Components from Server Components. Classes or null prototypes are not supported.",
487 "499": "Only plain objects, and a few built-ins, can be passed to Server Actions. Classes or null prototypes are not supported.",
488 - "500": "React expected a headers state to exist when emitEarlyPreloads was called but did not find it. This suggests emitEarlyPreloads was called more than once per request. This is a bug in React."
488 + "500": "React expected a headers state to exist when emitEarlyPreloads was called but did not find it. This suggests emitEarlyPreloads was called more than once per request. This is a bug in React.",
489 + "501": "The render was aborted with postpone when the shell is incomplete. Reason: %s"
490 }