@samitouri / QOS-React-2 / commits / ac1a16c67e

Add Postpone API (#27238)

This adds an experimental `unstable_postpone(reason)` API. Currently we don't have a way to model effectively an Infinite Promise. I.e. something that suspends but never resolves. The reason this is useful is because you might have something else that unblocks it later. E.g. by updating in place later, or by client rendering. On the client this works to model as an Infinite Promise (in fact, that's what this implementation does). However, in Fizz and Flight that doesn't work because the stream needs to end at some point. We don't have any way of knowing that we're suspended on infinite promises. It's not enough to tag the promises because you could await those and thus creating new promises. The only way we really have to signal this through a series of indirections like async functions, is by throwing. It's not 100% safe because these values can be caught but it's the best we can do. Effectively `postpone(reason)` behaves like a built-in [Catch Boundary](https://github.com/facebook/react/pull/26854). It's like `raise(Postpone, reason)` except it's built-in so it needs to be able to be encoded and caught by Suspense boundaries. In Flight and Fizz these behave pretty much the same as errors. Flight just forwards it to retrigger on the client. In Fizz they just trigger client rendering which itself might just postpone again or fill in the value. The difference is how they get logged. In Flight and Fizz they log to `onPostpone(reason)` instead of `onError(error)`. This log is meant to help find deopts on the server like finding places where you fall back to client rendering. The reason that you pass in is for that purpose to help the reason for any deopts. I do track the stack trace in DEV but I don't currently expose it to `onPostpone`. This seems like a limitation. It might be better to expose the Postpone object which is an Error object but that's more of an implementation detail. I could also pass it as a second argument. On the client after hydration they don't get passed to `onRecoverableError`. There's no global `onPostpone` API to capture postponed things on the client just like there's no `onError`. At that point it's just assumed to be intentional. It doesn't have any `digest` or reason passed to the client since it's not logged. There are some hacky solutions that currently just tries to reuse as much of the existing code as possible but should be more properly implemented. - Fiber is currently just converting it to a fake Promise object so that it behaves like an infinite Promise. - Fizz is encoding the magic digest string `"POSTPONE"` in the HTML so we know to ignore it but it should probably just be something neater that doesn't share namespace with digests. Next I plan on using this in the `/static` entry points for additional features. Why "postpone"? It's basically a synonym to "defer" but we plan on using "defer" for other purposes and it's overloaded anyway.

Sebastian Markbåge committed Aug 17, 2023 at 13:26 UTC ac1a16c67e268fcb2c52e91717cbc918c7c24446
34 files changed +662 -231
packages/react-client/src/ReactFlightClient.js
+77 -3
@@ -21,7 +21,9 @@ import type {HintModel} from 'react-server/src/ReactFlightServerConfig';
21
22 import type {CallServerCallback} from './ReactFlightReplyClient';
23
24 -import {enableBinaryFlight} from 'shared/ReactFeatureFlags';
24 +import type {Postpone} from 'react/src/ReactPostpone';
25 +
26 +import {enableBinaryFlight, enablePostpone} from 'shared/ReactFeatureFlags';
27
28 import {
29 resolveClientReference,
@@ -39,7 +41,11 @@ import {
41 knownServerReferences,
42 } from './ReactFlightReplyClient';
43
42 -import {REACT_LAZY_TYPE, REACT_ELEMENT_TYPE} from 'shared/ReactSymbols';
44 +import {
45 + REACT_LAZY_TYPE,
46 + REACT_ELEMENT_TYPE,
47 + REACT_POSTPONE_TYPE,
48 +} from 'shared/ReactSymbols';
49
50 import {getOrCreateServerContext} from 'shared/ReactServerContextRegistry';
51
@@ -226,7 +232,7 @@ function createBlockedChunk<T>(response: Response): BlockedChunk<T> {
232
233 function createErrorChunk<T>(
234 response: Response,
229 - error: ErrorWithDigest,
235 + error: Error | Postpone,
236 ): ErroredChunk<T> {
237 // $FlowFixMe[invalid-constructor] Flow doesn't support functions as constructors
238 return new Chunk(ERRORED, null, error, response);
@@ -867,6 +873,57 @@ function resolveErrorDev(
873 }
874 }
875
876 +function resolvePostponeProd(response: Response, id: number): void {
877 + if (__DEV__) {
878 + // These errors should never make it into a build so we don't need to encode them in codes.json
879 + // eslint-disable-next-line react-internal/prod-error-codes
880 + throw new Error(
881 + 'resolvePostponeProd should never be called in development mode. Use resolvePostponeDev instead. This is a bug in React.',
882 + );
883 + }
884 + const error = new Error(
885 + 'A Server Component was postponed. The reason is omitted in production' +
886 + ' builds to avoid leaking sensitive details.',
887 + );
888 + const postponeInstance: Postpone = (error: any);
889 + postponeInstance.$$typeof = REACT_POSTPONE_TYPE;
890 + postponeInstance.stack = 'Error: ' + error.message;
891 + const chunks = response._chunks;
892 + const chunk = chunks.get(id);
893 + if (!chunk) {
894 + chunks.set(id, createErrorChunk(response, postponeInstance));
895 + } else {
896 + triggerErrorOnChunk(chunk, postponeInstance);
897 + }
898 +}
899 +
900 +function resolvePostponeDev(
901 + response: Response,
902 + id: number,
903 + reason: string,
904 + stack: string,
905 +): void {
906 + if (!__DEV__) {
907 + // These errors should never make it into a build so we don't need to encode them in codes.json
908 + // eslint-disable-next-line react-internal/prod-error-codes
909 + throw new Error(
910 + 'resolvePostponeDev should never be called in production mode. Use resolvePostponeProd instead. This is a bug in React.',
911 + );
912 + }
913 + // eslint-disable-next-line react-internal/prod-error-codes
914 + const error = new Error(reason || '');
915 + const postponeInstance: Postpone = (error: any);
916 + postponeInstance.$$typeof = REACT_POSTPONE_TYPE;
917 + postponeInstance.stack = stack;
918 + const chunks = response._chunks;
919 + const chunk = chunks.get(id);
920 + if (!chunk) {
921 + chunks.set(id, createErrorChunk(response, postponeInstance));
922 + } else {
923 + triggerErrorOnChunk(chunk, postponeInstance);
924 + }
925 +}
926 +
927 function resolveHint(
928 response: Response,
929 code: string,
@@ -1019,6 +1076,23 @@ function processFullRow(
1076 resolveText(response, id, row);
1077 return;
1078 }
1079 + case 80 /* "P" */: {
1080 + if (enablePostpone) {
1081 + if (__DEV__) {
1082 + const postponeInfo = JSON.parse(row);
1083 + resolvePostponeDev(
1084 + response,
1085 + id,
1086 + postponeInfo.reason,
1087 + postponeInfo.stack,
1088 + );
1089 + } else {
1090 + resolvePostponeProd(response, id);
1091 + }
1092 + return;
1093 + }
1094 + }
1095 + // Fallthrough
1096 default: /* """ "{" "[" "t" "f" "n" "0" - "9" */ {
1097 // We assume anything else is JSON.
1098 resolveModel(response, id, row);
packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
+91
@@ -6040,4 +6040,95 @@ describe('ReactDOMFizzServer', () => {
6040 console.error = originalConsoleError;
6041 }
6042 });
6043 +
6044 + // @gate enablePostpone
6045 + it('client renders postponed boundaries without erroring', async () => {
6046 + function Postponed({isClient}) {
6047 + if (!isClient) {
6048 + React.unstable_postpone('testing postpone');
6049 + }
6050 + return 'client only';
6051 + }
6052 +
6053 + function App({isClient}) {
6054 + return (
6055 + <div>
6056 + <Suspense fallback={'loading...'}>
6057 + <Postponed isClient={isClient} />
6058 + </Suspense>
6059 + </div>
6060 + );
6061 + }
6062 +
6063 + const errors = [];
6064 +
6065 + await act(() => {
6066 + const {pipe} = renderToPipeableStream(<App isClient={false} />, {
6067 + onError(error) {
6068 + errors.push(error.message);
6069 + },
6070 + });
6071 + pipe(writable);
6072 + });
6073 +
6074 + expect(getVisibleChildren(container)).toEqual(<div>loading...</div>);
6075 +
6076 + ReactDOMClient.hydrateRoot(container, <App isClient={true} />, {
6077 + onRecoverableError(error) {
6078 + errors.push(error.message);
6079 + },
6080 + });
6081 + await waitForAll([]);
6082 + // Postponing should not be logged as a recoverable error since it's intentional.
6083 + expect(errors).toEqual([]);
6084 + expect(getVisibleChildren(container)).toEqual(<div>client only</div>);
6085 + });
6086 +
6087 + // @gate enablePostpone
6088 + it('errors if trying to postpone outside a Suspense boundary', async () => {
6089 + function Postponed() {
6090 + React.unstable_postpone('testing postpone');
6091 + return 'client only';
6092 + }
6093 +
6094 + function App() {
6095 + return (
6096 + <div>
6097 + <Postponed />
6098 + </div>
6099 + );
6100 + }
6101 +
6102 + const errors = [];
6103 + const fatalErrors = [];
6104 + const postponed = [];
6105 + let written = false;
6106 +
6107 + const testWritable = new Stream.Writable();
6108 + testWritable._write = (chunk, encoding, next) => {
6109 + written = true;
6110 + };
6111 +
6112 + await act(() => {
6113 + const {pipe} = renderToPipeableStream(<App />, {
6114 + onPostpone(reason) {
6115 + postponed.push(reason);
6116 + },
6117 + onError(error) {
6118 + errors.push(error.message);
6119 + },
6120 + onShellError(error) {
6121 + fatalErrors.push(error.message);
6122 + },
6123 + });
6124 + pipe(testWritable);
6125 + });
6126 +
6127 + expect(written).toBe(false);
6128 + // Postponing is not logged as an error but as a postponed reason.
6129 + expect(errors).toEqual([]);
6130 + expect(postponed).toEqual(['testing postpone']);
6131 + // However, it does error the shell.
6132 + expect(fatalErrors).toEqual(['testing postpone']);
6133 + });
6134 });
packages/react-dom/src/__tests__/ReactDOMFizzServerBrowser-test.js
+39
@@ -503,4 +503,43 @@ describe('ReactDOMFizzServerBrowser', () => {
503 `"<link rel="preload" href="init.js" as="script" fetchPriority="low" nonce="R4nd0m"/><link rel="modulepreload" href="init.mjs" fetchPriority="low" nonce="R4nd0m"/><div>hello world</div><script nonce="${nonce}">INIT();</script><script src="init.js" nonce="${nonce}" async=""></script><script type="module" src="init.mjs" nonce="${nonce}" async=""></script>"`,
504 );
505 });
506 +
507 + // @gate enablePostpone
508 + it('errors if trying to postpone outside a Suspense boundary', async () => {
509 + function Postponed() {
510 + React.unstable_postpone('testing postpone');
511 + return 'client only';
512 + }
513 +
514 + function App() {
515 + return (
516 + <div>
517 + <Postponed />
518 + </div>
519 + );
520 + }
521 +
522 + const errors = [];
523 + const postponed = [];
524 +
525 + let caughtError = null;
526 + try {
527 + await ReactDOMFizzServer.renderToReadableStream(<App />, {
528 + onError(error) {
529 + errors.push(error.message);
530 + },
531 + onPostpone(reason) {
532 + postponed.push(reason);
533 + },
534 + });
535 + } catch (error) {
536 + caughtError = error;
537 + }
538 +
539 + // Postponing is not logged as an error but as a postponed reason.
540 + expect(errors).toEqual([]);
541 + expect(postponed).toEqual(['testing postpone']);
542 + // However, it does error the shell.
543 + expect(caughtError.message).toEqual('testing postpone');
544 + });
545 });
packages/react-dom/src/server/ReactDOMFizzServerBrowser.js
+2
@@ -35,6 +35,7 @@ type Options = {
35 progressiveChunkSize?: number,
36 signal?: AbortSignal,
37 onError?: (error: mixed) => ?string,
38 + onPostpone?: (reason: string) => void,
39 unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
40 };
41
@@ -100,6 +101,7 @@ function renderToReadableStream(
101 onShellReady,
102 onShellError,
103 onFatalError,
104 + options ? options.onPostpone : undefined,
105 );
106 if (options && options.signal) {
107 const signal = options.signal;
packages/react-dom/src/server/ReactDOMFizzServerBun.js
+2
@@ -35,6 +35,7 @@ type Options = {
35 progressiveChunkSize?: number,
36 signal?: AbortSignal,
37 onError?: (error: mixed) => ?string,
38 + onPostpone?: (reason: string) => void,
39 unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
40 };
41
@@ -101,6 +102,7 @@ function renderToReadableStream(
102 onShellReady,
103 onShellError,
104 onFatalError,
105 + options ? options.onPostpone : undefined,
106 );
107 if (options && options.signal) {
108 const signal = options.signal;
packages/react-dom/src/server/ReactDOMFizzServerEdge.js
+2
@@ -35,6 +35,7 @@ type Options = {
35 progressiveChunkSize?: number,
36 signal?: AbortSignal,
37 onError?: (error: mixed) => ?string,
38 + onPostpone?: (reason: string) => void,
39 unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
40 };
41
@@ -100,6 +101,7 @@ function renderToReadableStream(
101 onShellReady,
102 onShellError,
103 onFatalError,
104 + options ? options.onPostpone : undefined,
105 );
106 if (options && options.signal) {
107 const signal = options.signal;
packages/react-dom/src/server/ReactDOMFizzServerNode.js
+2
@@ -49,6 +49,7 @@ type Options = {
49 onShellError?: (error: mixed) => void,
50 onAllReady?: () => void,
51 onError?: (error: mixed) => ?string,
52 + onPostpone?: (reason: string) => void,
53 unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
54 };
55
@@ -80,6 +81,7 @@ function createRequestImpl(children: ReactNodeList, options: void | Options) {
81 options ? options.onShellReady : undefined,
82 options ? options.onShellError : undefined,
83 undefined,
84 + options ? options.onPostpone : undefined,
85 );
86 }
87
packages/react-dom/src/server/ReactDOMFizzStaticBrowser.js
+2
@@ -34,6 +34,7 @@ type Options = {
34 progressiveChunkSize?: number,
35 signal?: AbortSignal,
36 onError?: (error: mixed) => ?string,
37 + onPostpone?: (reason: string) => void,
38 unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
39 };
40
@@ -85,6 +86,7 @@ function prerender(
86 undefined,
87 undefined,
88 onFatalError,
89 + options ? options.onPostpone : undefined,
90 );
91 if (options && options.signal) {
92 const signal = options.signal;
packages/react-dom/src/server/ReactDOMFizzStaticEdge.js
+2
@@ -34,6 +34,7 @@ type Options = {
34 progressiveChunkSize?: number,
35 signal?: AbortSignal,
36 onError?: (error: mixed) => ?string,
37 + onPostpone?: (reason: string) => void,
38 unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
39 };
40
@@ -85,6 +86,7 @@ function prerender(
86 undefined,
87 undefined,
88 onFatalError,
89 + options ? options.onPostpone : undefined,
90 );
91 if (options && options.signal) {
92 const signal = options.signal;
packages/react-dom/src/server/ReactDOMFizzStaticNode.js
+2
@@ -36,6 +36,7 @@ type Options = {
36 progressiveChunkSize?: number,
37 signal?: AbortSignal,
38 onError?: (error: mixed) => ?string,
39 + onPostpone?: (reason: string) => void,
40 unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
41 };
42
@@ -99,6 +100,7 @@ function prerenderToNodeStreams(
100 undefined,
101 undefined,
102 onFatalError,
103 + options ? options.onPostpone : undefined,
104 );
105 if (options && options.signal) {
106 const signal = options.signal;
packages/react-dom/src/server/ReactDOMLegacyServerImpl.js
+1
@@ -79,6 +79,7 @@ function renderToStringImpl(
79 onShellReady,
80 undefined,
81 undefined,
82 + undefined,
83 );
84 startWork(request);
85 // If anything suspended and is still pending, we'll abort it before writing.
packages/react-dom/src/server/ReactDOMLegacyServerNodeStream.js
+1
@@ -86,6 +86,7 @@ function renderToNodeStreamImpl(
86 onAllReady,
87 undefined,
88 undefined,
89 + undefined,
90 );
91 destination.request = request;
92 startWork(request);
packages/react-reconciler/src/ReactFiberBeginWork.js
+19 -13
@@ -109,6 +109,7 @@ import {
109 enableHostSingletons,
110 enableFormActions,
111 enableAsyncActions,
112 + enablePostpone,
113 } from 'shared/ReactFeatureFlags';
114 import isArray from 'shared/isArray';
115 import shallowEqual from 'shared/shallowEqual';
@@ -2859,7 +2860,8 @@ function updateDehydratedSuspenseComponent(
2860 // This boundary is in a permanent fallback state. In this case, we'll never
2861 // get an update and we'll never be able to hydrate the final content. Let's just try the
2862 // client side render instead.
2862 - let digest, message, stack;
2863 + let digest: ?string;
2864 + let message, stack;
2865 if (__DEV__) {
2866 ({digest, message, stack} =
2867 getSuspenseInstanceFallbackErrorDetails(suspenseInstance));
@@ -2867,19 +2869,23 @@ function updateDehydratedSuspenseComponent(
2869 ({digest} = getSuspenseInstanceFallbackErrorDetails(suspenseInstance));
2870 }
2871
2870 - let error;
2871 - if (message) {
2872 - // eslint-disable-next-line react-internal/prod-error-codes
2873 - error = new Error(message);
2874 - } else {
2875 - error = new Error(
2876 - 'The server could not finish this Suspense boundary, likely ' +
2877 - 'due to an error during server rendering. Switched to ' +
2878 - 'client rendering.',
2879 - );
2872 + let capturedValue = null;
2873 + // TODO: Figure out a better signal than encoding a magic digest value.
2874 + if (!enablePostpone || digest !== 'POSTPONE') {
2875 + let error;
2876 + if (message) {
2877 + // eslint-disable-next-line react-internal/prod-error-codes
2878 + error = new Error(message);
2879 + } else {
2880 + error = new Error(
2881 + 'The server could not finish this Suspense boundary, likely ' +
2882 + 'due to an error during server rendering. Switched to ' +
2883 + 'client rendering.',
2884 + );
2885 + }
2886 + (error: any).digest = digest;
2887 + capturedValue = createCapturedValue<mixed>(error, digest, stack);
2888 }
2881 - (error: any).digest = digest;
2882 - const capturedValue = createCapturedValue<mixed>(error, digest, stack);
2889 return retrySuspenseComponentWithoutHydrating(
2890 current,
2891 workInProgress,
packages/react-reconciler/src/ReactFiberThrow.js
+178 -173
@@ -41,6 +41,7 @@ import {
41 enableDebugTracing,
42 enableLazyContextPropagation,
43 enableUpdaterTracking,
44 + enablePostpone,
45 } from 'shared/ReactFeatureFlags';
46 import {createCapturedValueAtFiber} from './ReactCapturedValue';
47 import {
@@ -82,6 +83,7 @@ import {
83 } from './ReactFiberHydrationContext';
84 import {ConcurrentRoot} from './ReactRootTags';
85 import {noopSuspenseyCommitThenable} from './ReactFiberThenable';
86 +import {REACT_POSTPONE_TYPE} from 'shared/ReactSymbols';
87
88 function createRootErrorUpdate(
89 fiber: Fiber,
@@ -332,210 +334,213 @@ function throwException(
334 }
335 }
336
335 - if (
336 - value !== null &&
337 - typeof value === 'object' &&
338 - typeof value.then === 'function'
339 - ) {
340 - // This is a wakeable. The component suspended.
341 - const wakeable: Wakeable = (value: any);
342 - resetSuspendedComponent(sourceFiber, rootRenderLanes);
337 + if (value !== null && typeof value === 'object') {
338 + if (enablePostpone && value.$$typeof === REACT_POSTPONE_TYPE) {
339 + // Act as if this is an infinitely suspending promise.
340 + value = {then: function () {}};
341 + }
342 + if (typeof value.then === 'function') {
343 + // This is a wakeable. The component suspended.
344 + const wakeable: Wakeable = (value: any);
345 + resetSuspendedComponent(sourceFiber, rootRenderLanes);
346
344 - if (__DEV__) {
345 - if (getIsHydrating() && sourceFiber.mode & ConcurrentMode) {
346 - markDidThrowWhileHydratingDEV();
347 + if (__DEV__) {
348 + if (getIsHydrating() && sourceFiber.mode & ConcurrentMode) {
349 + markDidThrowWhileHydratingDEV();
350 + }
351 }
348 - }
352
350 - if (__DEV__) {
351 - if (enableDebugTracing) {
352 - if (sourceFiber.mode & DebugTracingMode) {
353 - const name = getComponentNameFromFiber(sourceFiber) || 'Unknown';
354 - logComponentSuspended(name, wakeable);
353 + if (__DEV__) {
354 + if (enableDebugTracing) {
355 + if (sourceFiber.mode & DebugTracingMode) {
356 + const name = getComponentNameFromFiber(sourceFiber) || 'Unknown';
357 + logComponentSuspended(name, wakeable);
358 + }
359 }
360 }
357 - }
361
359 - // Mark the nearest Suspense boundary to switch to rendering a fallback.
360 - const suspenseBoundary = getSuspenseHandler();
361 - if (suspenseBoundary !== null) {
362 - switch (suspenseBoundary.tag) {
363 - case SuspenseComponent: {
364 - // If this suspense boundary is not already showing a fallback, mark
365 - // the in-progress render as suspended. We try to perform this logic
366 - // as soon as soon as possible during the render phase, so the work
367 - // loop can know things like whether it's OK to switch to other tasks,
368 - // or whether it can wait for data to resolve before continuing.
369 - // TODO: Most of these checks are already performed when entering a
370 - // Suspense boundary. We should track the information on the stack so
371 - // we don't have to recompute it on demand. This would also allow us
372 - // to unify with `use` which needs to perform this logic even sooner,
373 - // before `throwException` is called.
374 - if (sourceFiber.mode & ConcurrentMode) {
375 - if (getShellBoundary() === null) {
376 - // Suspended in the "shell" of the app. This is an undesirable
377 - // loading state. We should avoid committing this tree.
378 - renderDidSuspendDelayIfPossible();
379 - } else {
380 - // If we suspended deeper than the shell, we don't need to delay
381 - // the commmit. However, we still call renderDidSuspend if this is
382 - // a new boundary, to tell the work loop that a new fallback has
383 - // appeared during this render.
384 - // TODO: Theoretically we should be able to delete this branch.
385 - // It's currently used for two things: 1) to throttle the
386 - // appearance of successive loading states, and 2) in
387 - // SuspenseList, to determine whether the children include any
388 - // pending fallbacks. For 1, we should apply throttling to all
389 - // retries, not just ones that render an additional fallback. For
390 - // 2, we should check subtreeFlags instead. Then we can delete
391 - // this branch.
392 - const current = suspenseBoundary.alternate;
393 - if (current === null) {
394 - renderDidSuspend();
362 + // Mark the nearest Suspense boundary to switch to rendering a fallback.
363 + const suspenseBoundary = getSuspenseHandler();
364 + if (suspenseBoundary !== null) {
365 + switch (suspenseBoundary.tag) {
366 + case SuspenseComponent: {
367 + // If this suspense boundary is not already showing a fallback, mark
368 + // the in-progress render as suspended. We try to perform this logic
369 + // as soon as soon as possible during the render phase, so the work
370 + // loop can know things like whether it's OK to switch to other tasks,
371 + // or whether it can wait for data to resolve before continuing.
372 + // TODO: Most of these checks are already performed when entering a
373 + // Suspense boundary. We should track the information on the stack so
374 + // we don't have to recompute it on demand. This would also allow us
375 + // to unify with `use` which needs to perform this logic even sooner,
376 + // before `throwException` is called.
377 + if (sourceFiber.mode & ConcurrentMode) {
378 + if (getShellBoundary() === null) {
379 + // Suspended in the "shell" of the app. This is an undesirable
380 + // loading state. We should avoid committing this tree.
381 + renderDidSuspendDelayIfPossible();
382 + } else {
383 + // If we suspended deeper than the shell, we don't need to delay
384 + // the commmit. However, we still call renderDidSuspend if this is
385 + // a new boundary, to tell the work loop that a new fallback has
386 + // appeared during this render.
387 + // TODO: Theoretically we should be able to delete this branch.
388 + // It's currently used for two things: 1) to throttle the
389 + // appearance of successive loading states, and 2) in
390 + // SuspenseList, to determine whether the children include any
391 + // pending fallbacks. For 1, we should apply throttling to all
392 + // retries, not just ones that render an additional fallback. For
393 + // 2, we should check subtreeFlags instead. Then we can delete
394 + // this branch.
395 + const current = suspenseBoundary.alternate;
396 + if (current === null) {
397 + renderDidSuspend();
398 + }
399 }
400 }
397 - }
401
399 - suspenseBoundary.flags &= ~ForceClientRender;
400 - markSuspenseBoundaryShouldCapture(
401 - suspenseBoundary,
402 - returnFiber,
403 - sourceFiber,
404 - root,
405 - rootRenderLanes,
406 - );
407 - // Retry listener
408 - //
409 - // If the fallback does commit, we need to attach a different type of
410 - // listener. This one schedules an update on the Suspense boundary to
411 - // turn the fallback state off.
412 - //
413 - // Stash the wakeable on the boundary fiber so we can access it in the
414 - // commit phase.
415 - //
416 - // When the wakeable resolves, we'll attempt to render the boundary
417 - // again ("retry").
402 + suspenseBoundary.flags &= ~ForceClientRender;
403 + markSuspenseBoundaryShouldCapture(
404 + suspenseBoundary,
405 + returnFiber,
406 + sourceFiber,
407 + root,
408 + rootRenderLanes,
409 + );
410 + // Retry listener
411 + //
412 + // If the fallback does commit, we need to attach a different type of
413 + // listener. This one schedules an update on the Suspense boundary to
414 + // turn the fallback state off.
415 + //
416 + // Stash the wakeable on the boundary fiber so we can access it in the
417 + // commit phase.
418 + //
419 + // When the wakeable resolves, we'll attempt to render the boundary
420 + // again ("retry").
421
419 - // Check if this is a Suspensey resource. We do not attach retry
420 - // listeners to these, because we don't actually need them for
421 - // rendering. Only for committing. Instead, if a fallback commits
422 - // and the only thing that suspended was a Suspensey resource, we
423 - // retry immediately.
424 - // TODO: Refactor throwException so that we don't have to do this type
425 - // check. The caller already knows what the cause was.
426 - const isSuspenseyResource = wakeable === noopSuspenseyCommitThenable;
427 - if (isSuspenseyResource) {
428 - suspenseBoundary.flags |= ScheduleRetry;
429 - } else {
430 - const retryQueue: RetryQueue | null =
431 - (suspenseBoundary.updateQueue: any);
432 - if (retryQueue === null) {
433 - suspenseBoundary.updateQueue = new Set([wakeable]);
434 - } else {
435 - retryQueue.add(wakeable);
436 - }
437 - }
438 - break;
439 - }
440 - case OffscreenComponent: {
441 - if (suspenseBoundary.mode & ConcurrentMode) {
442 - suspenseBoundary.flags |= ShouldCapture;
422 + // Check if this is a Suspensey resource. We do not attach retry
423 + // listeners to these, because we don't actually need them for
424 + // rendering. Only for committing. Instead, if a fallback commits
425 + // and the only thing that suspended was a Suspensey resource, we
426 + // retry immediately.
427 + // TODO: Refactor throwException so that we don't have to do this type
428 + // check. The caller already knows what the cause was.
429 const isSuspenseyResource =
430 wakeable === noopSuspenseyCommitThenable;
431 if (isSuspenseyResource) {
432 suspenseBoundary.flags |= ScheduleRetry;
433 } else {
448 - const offscreenQueue: OffscreenQueue | null =
434 + const retryQueue: RetryQueue | null =
435 (suspenseBoundary.updateQueue: any);
450 - if (offscreenQueue === null) {
451 - const newOffscreenQueue: OffscreenQueue = {
452 - transitions: null,
453 - markerInstances: null,
454 - retryQueue: new Set([wakeable]),
455 - };
456 - suspenseBoundary.updateQueue = newOffscreenQueue;
436 + if (retryQueue === null) {
437 + suspenseBoundary.updateQueue = new Set([wakeable]);
438 + } else {
439 + retryQueue.add(wakeable);
440 + }
441 + }
442 + break;
443 + }
444 + case OffscreenComponent: {
445 + if (suspenseBoundary.mode & ConcurrentMode) {
446 + suspenseBoundary.flags |= ShouldCapture;
447 + const isSuspenseyResource =
448 + wakeable === noopSuspenseyCommitThenable;
449 + if (isSuspenseyResource) {
450 + suspenseBoundary.flags |= ScheduleRetry;
451 } else {
458 - const retryQueue = offscreenQueue.retryQueue;
459 - if (retryQueue === null) {
460 - offscreenQueue.retryQueue = new Set([wakeable]);
452 + const offscreenQueue: OffscreenQueue | null =
453 + (suspenseBoundary.updateQueue: any);
454 + if (offscreenQueue === null) {
455 + const newOffscreenQueue: OffscreenQueue = {
456 + transitions: null,
457 + markerInstances: null,
458 + retryQueue: new Set([wakeable]),
459 + };
460 + suspenseBoundary.updateQueue = newOffscreenQueue;
461 } else {
462 - retryQueue.add(wakeable);
462 + const retryQueue = offscreenQueue.retryQueue;
463 + if (retryQueue === null) {
464 + offscreenQueue.retryQueue = new Set([wakeable]);
465 + } else {
466 + retryQueue.add(wakeable);
467 + }
468 }
469 }
470 + break;
471 }
466 - break;
472 + // Fall through
473 + }
474 + default: {
475 + throw new Error(
476 + `Unexpected Suspense handler tag (${suspenseBoundary.tag}). This ` +
477 + 'is a bug in React.',
478 + );
479 }
468 - // Fall through
480 }
470 - default: {
471 - throw new Error(
472 - `Unexpected Suspense handler tag (${suspenseBoundary.tag}). This ` +
473 - 'is a bug in React.',
474 - );
481 + // We only attach ping listeners in concurrent mode. Legacy Suspense always
482 + // commits fallbacks synchronously, so there are no pings.
483 + if (suspenseBoundary.mode & ConcurrentMode) {
484 + attachPingListener(root, wakeable, rootRenderLanes);
485 }
476 - }
477 - // We only attach ping listeners in concurrent mode. Legacy Suspense always
478 - // commits fallbacks synchronously, so there are no pings.
479 - if (suspenseBoundary.mode & ConcurrentMode) {
480 - attachPingListener(root, wakeable, rootRenderLanes);
481 - }
482 - return;
483 - } else {
484 - // No boundary was found. Unless this is a sync update, this is OK.
485 - // We can suspend and wait for more data to arrive.
486 -
487 - if (root.tag === ConcurrentRoot) {
488 - // In a concurrent root, suspending without a Suspense boundary is
489 - // allowed. It will suspend indefinitely without committing.
490 - //
491 - // TODO: Should we have different behavior for discrete updates? What
492 - // about flushSync? Maybe it should put the tree into an inert state,
493 - // and potentially log a warning. Revisit this for a future release.
494 - attachPingListener(root, wakeable, rootRenderLanes);
495 - renderDidSuspendDelayIfPossible();
486 return;
487 } else {
498 - // In a legacy root, suspending without a boundary is always an error.
499 - const uncaughtSuspenseError = new Error(
500 - 'A component suspended while responding to synchronous input. This ' +
501 - 'will cause the UI to be replaced with a loading indicator. To ' +
502 - 'fix, updates that suspend should be wrapped ' +
503 - 'with startTransition.',
504 - );
505 - value = uncaughtSuspenseError;
488 + // No boundary was found. Unless this is a sync update, this is OK.
489 + // We can suspend and wait for more data to arrive.
490 +
491 + if (root.tag === ConcurrentRoot) {
492 + // In a concurrent root, suspending without a Suspense boundary is
493 + // allowed. It will suspend indefinitely without committing.
494 + //
495 + // TODO: Should we have different behavior for discrete updates? What
496 + // about flushSync? Maybe it should put the tree into an inert state,
497 + // and potentially log a warning. Revisit this for a future release.
498 + attachPingListener(root, wakeable, rootRenderLanes);
499 + renderDidSuspendDelayIfPossible();
500 + return;
501 + } else {
502 + // In a legacy root, suspending without a boundary is always an error.
503 + const uncaughtSuspenseError = new Error(
504 + 'A component suspended while responding to synchronous input. This ' +
505 + 'will cause the UI to be replaced with a loading indicator. To ' +
506 + 'fix, updates that suspend should be wrapped ' +
507 + 'with startTransition.',
508 + );
509 + value = uncaughtSuspenseError;
510 + }
511 }
512 }
508 - } else {
509 - // This is a regular error, not a Suspense wakeable.
510 - if (getIsHydrating() && sourceFiber.mode & ConcurrentMode) {
511 - markDidThrowWhileHydratingDEV();
512 - const suspenseBoundary = getSuspenseHandler();
513 - // If the error was thrown during hydration, we may be able to recover by
514 - // discarding the dehydrated content and switching to a client render.
515 - // Instead of surfacing the error, find the nearest Suspense boundary
516 - // and render it again without hydration.
517 - if (suspenseBoundary !== null) {
518 - if ((suspenseBoundary.flags & ShouldCapture) === NoFlags) {
519 - // Set a flag to indicate that we should try rendering the normal
520 - // children again, not the fallback.
521 - suspenseBoundary.flags |= ForceClientRender;
522 - }
523 - markSuspenseBoundaryShouldCapture(
524 - suspenseBoundary,
525 - returnFiber,
526 - sourceFiber,
527 - root,
528 - rootRenderLanes,
529 - );
513 + }
514
531 - // Even though the user may not be affected by this error, we should
532 - // still log it so it can be fixed.
533 - queueHydrationError(createCapturedValueAtFiber(value, sourceFiber));
534 - return;
515 + // This is a regular error, not a Suspense wakeable.
516 + if (getIsHydrating() && sourceFiber.mode & ConcurrentMode) {
517 + markDidThrowWhileHydratingDEV();
518 + const suspenseBoundary = getSuspenseHandler();
519 + // If the error was thrown during hydration, we may be able to recover by
520 + // discarding the dehydrated content and switching to a client render.
521 + // Instead of surfacing the error, find the nearest Suspense boundary
522 + // and render it again without hydration.
523 + if (suspenseBoundary !== null) {
524 + if ((suspenseBoundary.flags & ShouldCapture) === NoFlags) {
525 + // Set a flag to indicate that we should try rendering the normal
526 + // children again, not the fallback.
527 + suspenseBoundary.flags |= ForceClientRender;
528 }
536 - } else {
537 - // Otherwise, fall through to the error path.
529 + markSuspenseBoundaryShouldCapture(
530 + suspenseBoundary,
531 + returnFiber,
532 + sourceFiber,
533 + root,
534 + rootRenderLanes,
535 + );
536 +
537 + // Even though the user may not be affected by this error, we should
538 + // still log it so it can be fixed.
539 + queueHydrationError(createCapturedValueAtFiber(value, sourceFiber));
540 + return;
541 }
542 + } else {
543 + // Otherwise, fall through to the error path.
544 }
545
546 value = createCapturedValueAtFiber(value, sourceFiber);
packages/react-server-dom-esm/src/ReactFlightDOMServerNode.js
+2
@@ -49,6 +49,7 @@ function createDrainHandler(destination: Destination, request: Request) {
49
50 type Options = {
51 onError?: (error: mixed) => void,
52 + onPostpone?: (reason: string) => void,
53 context?: Array<[string, ServerContextJSONValue]>,
54 identifierPrefix?: string,
55 };
@@ -69,6 +70,7 @@ function renderToPipeableStream(
70 options ? options.onError : undefined,
71 options ? options.context : undefined,
72 options ? options.identifierPrefix : undefined,
73 + options ? options.onPostpone : undefined,
74 );
75 let hasStartedFlowing = false;
76 startWork(request);
packages/react-server-dom-webpack/src/ReactFlightDOMServerBrowser.js
+2
@@ -38,6 +38,7 @@ type Options = {
38 signal?: AbortSignal,
39 context?: Array<[string, ServerContextJSONValue]>,
40 onError?: (error: mixed) => void,
41 + onPostpone?: (reason: string) => void,
42 };
43
44 function renderToReadableStream(
@@ -51,6 +52,7 @@ function renderToReadableStream(
52 options ? options.onError : undefined,
53 options ? options.context : undefined,
54 options ? options.identifierPrefix : undefined,
55 + options ? options.onPostpone : undefined,
56 );
57 if (options && options.signal) {
58 const signal = options.signal;
packages/react-server-dom-webpack/src/ReactFlightDOMServerEdge.js
+2
@@ -38,6 +38,7 @@ type Options = {
38 signal?: AbortSignal,
39 context?: Array<[string, ServerContextJSONValue]>,
40 onError?: (error: mixed) => void,
41 + onPostpone?: (reason: string) => void,
42 };
43
44 function renderToReadableStream(
@@ -51,6 +52,7 @@ function renderToReadableStream(
52 options ? options.onError : undefined,
53 options ? options.context : undefined,
54 options ? options.identifierPrefix : undefined,
55 + options ? options.onPostpone : undefined,
56 );
57 if (options && options.signal) {
58 const signal = options.signal;
packages/react-server-dom-webpack/src/ReactFlightDOMServerNode.js
+2
@@ -50,6 +50,7 @@ function createDrainHandler(destination: Destination, request: Request) {
50
51 type Options = {
52 onError?: (error: mixed) => void,
53 + onPostpone?: (reason: string) => void,
54 context?: Array<[string, ServerContextJSONValue]>,
55 identifierPrefix?: string,
56 };
@@ -70,6 +71,7 @@ function renderToPipeableStream(
71 options ? options.onError : undefined,
72 options ? options.context : undefined,
73 options ? options.identifierPrefix : undefined,
74 + options ? options.onPostpone : undefined,
75 );
76 let hasStartedFlowing = false;
77 startWork(request);
packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js
+44
@@ -1178,4 +1178,48 @@ describe('ReactFlightDOMBrowser', () => {
1178 '</head><body><p>hello world</p></body></html>',
1179 );
1180 });
1181 +
1182 + // @gate enablePostpone
1183 + it('supports postpone in Server Components', async () => {
1184 + function Server() {
1185 + React.unstable_postpone('testing postpone');
1186 + return 'Not shown';
1187 + }
1188 +
1189 + let postponed = null;
1190 +
1191 + const stream = ReactServerDOMServer.renderToReadableStream(
1192 + <Suspense fallback="Loading...">
1193 + <Server />
1194 + </Suspense>,
1195 + null,
1196 + {
1197 + onPostpone(reason) {
1198 + postponed = reason;
1199 + },
1200 + },
1201 + );
1202 + const response = ReactServerDOMClient.createFromReadableStream(stream);
1203 +
1204 + function Client() {
1205 + return use(response);
1206 + }
1207 +
1208 + const container = document.createElement('div');
1209 + const root = ReactDOMClient.createRoot(container);
1210 + await act(async () => {
1211 + root.render(
1212 + <div>
1213 + Shell: <Client />
1214 + </div>,
1215 + );
1216 + });
1217 + // We should have reserved the shell already. Which means that the Server
1218 + // Component should've been a lazy component.
1219 + expect(container.innerHTML).toContain('Shell:');
1220 + expect(container.innerHTML).toContain('Loading...');
1221 + expect(container.innerHTML).not.toContain('Not shown');
1222 +
1223 + expect(postponed).toBe('testing postpone');
1224 + });
1225 });
packages/react-server/src/ReactFizzServer.js
+43 -2
@@ -128,6 +128,7 @@ import {
128 REACT_SERVER_CONTEXT_TYPE,
129 REACT_SCOPE_TYPE,
130 REACT_OFFSCREEN_TYPE,
131 + REACT_POSTPONE_TYPE,
132 } from 'shared/ReactSymbols';
133 import ReactSharedInternals from 'shared/ReactSharedInternals';
134 import {
@@ -137,12 +138,14 @@ import {
138 enableSuspenseAvoidThisFallbackFizz,
139 enableFloat,
140 enableCache,
141 + enablePostpone,
142 } from 'shared/ReactFeatureFlags';
143
144 import assign from 'shared/assign';
145 import getComponentNameFromType from 'shared/getComponentNameFromType';
146 import isArray from 'shared/isArray';
147 import {SuspenseException, getSuspendedThenable} from './ReactFizzThenable';
148 +import type {Postpone} from 'react/src/ReactPostpone';
149
150 const ReactCurrentDispatcher = ReactSharedInternals.ReactCurrentDispatcher;
151 const ReactCurrentCache = ReactSharedInternals.ReactCurrentCache;
@@ -241,6 +244,9 @@ export opaque type Request = {
244 // emit a different response to the stream instead.
245 onShellError: (error: mixed) => void,
246 onFatalError: (error: mixed) => void,
247 + // onPostpone is called when postpone() is called anywhere in the tree, which will defer
248 + // rendering - e.g. to the client. This is considered intentional and not an error.
249 + onPostpone: (reason: string) => void,
250 };
251
252 // This is a default heuristic for how to split up the HTML content into progressive
@@ -278,6 +284,7 @@ export function createRequest(
284 onShellReady: void | (() => void),
285 onShellError: void | ((error: mixed) => void),
286 onFatalError: void | ((error: mixed) => void),
287 + onPostpone: void | ((reason: string) => void),
288 ): Request {
289 prepareHostDispatcher();
290 const pingedTasks: Array<Task> = [];
@@ -303,6 +310,7 @@ export function createRequest(
310 completedBoundaries: ([]: Array<SuspenseBoundary>),
311 partialBoundaries: ([]: Array<SuspenseBoundary>),
312 onError: onError === undefined ? defaultErrorHandler : onError,
313 + onPostpone: onPostpone === undefined ? noop : onPostpone,
314 onAllReady: onAllReady === undefined ? noop : onAllReady,
315 onShellReady: onShellReady === undefined ? noop : onShellReady,
316 onShellError: onShellError === undefined ? noop : onShellError,
@@ -508,6 +516,12 @@ function captureBoundaryErrorDetailsDev(
516 }
517 }
518
519 +function logPostpone(request: Request, reason: string): void {
520 + // If this callback errors, we intentionally let that error bubble up to become a fatal error
521 + // so that someone fixes the error reporting instead of hiding it.
522 + request.onPostpone(reason);
523 +}
524 +
525 function logRecoverableError(request: Request, error: any): ?string {
526 // If this callback errors, we intentionally let that error bubble up to become a fatal error
527 // so that someone fixes the error reporting instead of hiding it.
@@ -622,7 +636,21 @@ function renderSuspenseBoundary(
636 } catch (error) {
637 contentRootSegment.status = ERRORED;
638 newBoundary.forceClientRender = true;
625 - newBoundary.errorDigest = logRecoverableError(request, error);
639 + let errorDigest;
640 + if (
641 + enablePostpone &&
642 + typeof error === 'object' &&
643 + error !== null &&
644 + error.$$typeof === REACT_POSTPONE_TYPE
645 + ) {
646 + const postponeInstance: Postpone = (error: any);
647 + logPostpone(request, postponeInstance.message);
648 + // TODO: Figure out a better signal than a magic digest value.
649 + errorDigest = 'POSTPONE';
650 + } else {
651 + errorDigest = logRecoverableError(request, error);
652 + }
653 + newBoundary.errorDigest = errorDigest;
654 if (__DEV__) {
655 captureBoundaryErrorDetailsDev(newBoundary, error);
656 }
@@ -1678,7 +1706,20 @@ function erroredTask(
1706 error: mixed,
1707 ) {
1708 // Report the error to a global handler.
1681 - const errorDigest = logRecoverableError(request, error);
1709 + let errorDigest;
1710 + if (
1711 + enablePostpone &&
1712 + typeof error === 'object' &&
1713 + error !== null &&
1714 + error.$$typeof === REACT_POSTPONE_TYPE
1715 + ) {
1716 + const postponeInstance: Postpone = (error: any);
1717 + logPostpone(request, postponeInstance.message);
1718 + // TODO: Figure out a better signal than a magic digest value.
1719 + errorDigest = 'POSTPONE';
1720 + } else {
1721 + errorDigest = logRecoverableError(request, error);
1722 + }
1723 if (boundary === null) {
1724 fatalError(request, error);
1725 } else {
packages/react-server/src/ReactFlightServer.js
+106 -38
@@ -9,7 +9,9 @@
9
10 import type {Chunk, BinaryChunk, Destination} from './ReactServerStreamConfig';
11
12 -import {enableBinaryFlight} from 'shared/ReactFeatureFlags';
12 +import type {Postpone} from 'react/src/ReactPostpone';
13 +
14 +import {enableBinaryFlight, enablePostpone} from 'shared/ReactFeatureFlags';
15
16 import {
17 scheduleWork,
@@ -87,6 +89,7 @@ import {
89 REACT_FRAGMENT_TYPE,
90 REACT_LAZY_TYPE,
91 REACT_MEMO_TYPE,
92 + REACT_POSTPONE_TYPE,
93 REACT_PROVIDER_TYPE,
94 } from 'shared/ReactSymbols';
95
@@ -189,6 +192,7 @@ export type Request = {
192 identifierPrefix: string,
193 identifierCount: number,
194 onError: (error: mixed) => ?string,
195 + onPostpone: (reason: string) => void,
196 toJSON: (key: string, value: ReactClientValue) => ReactJSONValue,
197 };
198
@@ -200,6 +204,10 @@ function defaultErrorHandler(error: mixed) {
204 // Don't transform to our wrapper
205 }
206
207 +function defaultPostponeHandler(reason: string) {
208 + // Noop
209 +}
210 +
211 const OPEN = 0;
212 const CLOSING = 1;
213 const CLOSED = 2;
@@ -210,6 +218,7 @@ export function createRequest(
218 onError: void | ((error: mixed) => ?string),
219 context?: Array<[string, ServerContextJSONValue]>,
220 identifierPrefix?: string,
221 + onPostpone: void | ((reason: string) => void),
222 ): Request {
223 if (
224 ReactCurrentCache.current !== null &&
@@ -248,6 +257,7 @@ export function createRequest(
257 identifierPrefix: identifierPrefix || '',
258 identifierCount: 1,
259 onError: onError === undefined ? defaultErrorHandler : onError,
260 + onPostpone: onPostpone === undefined ? defaultPostponeHandler : onPostpone,
261 // $FlowFixMe[missing-this-annot]
262 toJSON: function (key: string, value: ReactClientValue): ReactJSONValue {
263 return resolveModelToJSON(request, this, key, value);
@@ -297,8 +307,19 @@ function serializeThenable(request: Request, thenable: Thenable<any>): number {
307 }
308 case 'rejected': {
309 const x = thenable.reason;
300 - const digest = logRecoverableError(request, x);
301 - emitErrorChunk(request, newTask.id, digest, x);
310 + if (
311 + enablePostpone &&
312 + typeof x === 'object' &&
313 + x !== null &&
314 + (x: any).$$typeof === REACT_POSTPONE_TYPE
315 + ) {
316 + const postponeInstance: Postpone = (x: any);
317 + logPostpone(request, postponeInstance.message);
318 + emitPostponeChunk(request, newTask.id, postponeInstance);
319 + } else {
320 + const digest = logRecoverableError(request, x);
321 + emitErrorChunk(request, newTask.id, digest, x);
322 + }
323 return newTask.id;
324 }
325 default: {
@@ -893,30 +914,40 @@ function resolveModelToJSON(
914 // later, once we deprecate the old API in favor of `use`.
915 getSuspendedThenable()
916 : thrownValue;
896 - // $FlowFixMe[method-unbinding]
897 - if (typeof x === 'object' && x !== null && typeof x.then === 'function') {
898 - // Something suspended, we'll need to create a new task and resolve it later.
899 - request.pendingChunks++;
900 - const newTask = createTask(
901 - request,
902 - value,
903 - getActiveContext(),
904 - request.abortableTasks,
905 - );
906 - const ping = newTask.ping;
907 - x.then(ping, ping);
908 - newTask.thenableState = getThenableStateAfterSuspending();
909 - return serializeLazyID(newTask.id);
910 - } else {
911 - // Something errored. We'll still send everything we have up until this point.
912 - // We'll replace this element with a lazy reference that throws on the client
913 - // once it gets rendered.
914 - request.pendingChunks++;
915 - const errorId = request.nextChunkId++;
916 - const digest = logRecoverableError(request, x);
917 - emitErrorChunk(request, errorId, digest, x);
918 - return serializeLazyID(errorId);
917 + if (typeof x === 'object' && x !== null) {
918 + // $FlowFixMe[method-unbinding]
919 + if (typeof x.then === 'function') {
920 + // Something suspended, we'll need to create a new task and resolve it later.
921 + request.pendingChunks++;
922 + const newTask = createTask(
923 + request,
924 + value,
925 + getActiveContext(),
926 + request.abortableTasks,
927 + );
928 + const ping = newTask.ping;
929 + x.then(ping, ping);
930 + newTask.thenableState = getThenableStateAfterSuspending();
931 + return serializeLazyID(newTask.id);
932 + } else if (enablePostpone && x.$$typeof === REACT_POSTPONE_TYPE) {
933 + // Something postponed. We'll still send everything we have up until this point.
934 + // We'll replace this element with a lazy reference that postpones on the client.
935 + const postponeInstance: Postpone = (x: any);
936 + request.pendingChunks++;
937 + const postponeId = request.nextChunkId++;
938 + logPostpone(request, postponeInstance.message);
939 + emitPostponeChunk(request, postponeId, postponeInstance);
940 + return serializeLazyID(postponeId);
941 + }
942 }
943 + // Something errored. We'll still send everything we have up until this point.
944 + // We'll replace this element with a lazy reference that throws on the client
945 + // once it gets rendered.
946 + request.pendingChunks++;
947 + const errorId = request.nextChunkId++;
948 + const digest = logRecoverableError(request, x);
949 + emitErrorChunk(request, errorId, digest, x);
950 + return serializeLazyID(errorId);
951 }
952 }
953
@@ -1146,6 +1177,11 @@ function resolveModelToJSON(
1177 );
1178 }
1179
1180 +function logPostpone(request: Request, reason: string): void {
1181 + const onPostpone = request.onPostpone;
1182 + onPostpone(reason);
1183 +}
1184 +
1185 function logRecoverableError(request: Request, error: mixed): string {
1186 const onError = request.onError;
1187 const errorDigest = onError(error);
@@ -1169,6 +1205,30 @@ function fatalError(request: Request, error: mixed): void {
1205 }
1206 }
1207
1208 +function emitPostponeChunk(
1209 + request: Request,
1210 + id: number,
1211 + postponeInstance: Postpone,
1212 +): void {
1213 + let row;
1214 + if (__DEV__) {
1215 + let reason = '';
1216 + let stack = '';
1217 + try {
1218 + // eslint-disable-next-line react-internal/safe-string-coercion
1219 + reason = String(postponeInstance.message);
1220 + // eslint-disable-next-line react-internal/safe-string-coercion
1221 + stack = String(postponeInstance.stack);
1222 + } catch (x) {}
1223 + row = serializeRowHeader('P', id) + stringify({reason, stack}) + '\n';
1224 + } else {
1225 + // No reason included in prod.
1226 + row = serializeRowHeader('P', id) + '\n';
1227 + }
1228 + const processedChunk = stringToChunk(row);
1229 + request.completedErrorChunks.push(processedChunk);
1230 +}
1231 +
1232 function emitErrorChunk(
1233 request: Request,
1234 id: number,
@@ -1321,19 +1381,27 @@ function retryTask(request: Request, task: Task): void {
1381 // later, once we deprecate the old API in favor of `use`.
1382 getSuspendedThenable()
1383 : thrownValue;
1324 - // $FlowFixMe[method-unbinding]
1325 - if (typeof x === 'object' && x !== null && typeof x.then === 'function') {
1326 - // Something suspended again, let's pick it back up later.
1327 - const ping = task.ping;
1328 - x.then(ping, ping);
1329 - task.thenableState = getThenableStateAfterSuspending();
1330 - return;
1331 - } else {
1332 - request.abortableTasks.delete(task);
1333 - task.status = ERRORED;
1334 - const digest = logRecoverableError(request, x);
1335 - emitErrorChunk(request, task.id, digest, x);
1384 + if (typeof x === 'object' && x !== null) {
1385 + // $FlowFixMe[method-unbinding]
1386 + if (typeof x.then === 'function') {
1387 + // Something suspended again, let's pick it back up later.
1388 + const ping = task.ping;
1389 + x.then(ping, ping);
1390 + task.thenableState = getThenableStateAfterSuspending();
1391 + return;
1392 + } else if (enablePostpone && x.$$typeof === REACT_POSTPONE_TYPE) {
1393 + request.abortableTasks.delete(task);
1394 + task.status = ERRORED;
1395 + const postponeInstance: Postpone = (x: any);
1396 + logPostpone(request, postponeInstance.message);
1397 + emitPostponeChunk(request, task.id, postponeInstance);
1398 + return;
1399 + }
1400 }
1401 + request.abortableTasks.delete(task);
1402 + task.status = ERRORED;
1403 + const digest = logRecoverableError(request, x);
1404 + emitErrorChunk(request, task.id, digest, x);
1405 }
1406 }
1407
packages/react/index.experimental.js
+1
@@ -33,6 +33,7 @@ export {
33 unstable_Cache,
34 unstable_DebugTracingMode,
35 unstable_Offscreen,
36 + unstable_postpone,
37 unstable_getCacheSignal,
38 unstable_getCacheForType,
39 unstable_SuspenseList,
packages/react/src/React.js
+2
@@ -36,6 +36,7 @@ import {lazy} from './ReactLazy';
36 import {forwardRef} from './ReactForwardRef';
37 import {memo} from './ReactMemo';
38 import {cache} from './ReactCache';
39 +import {postpone} from './ReactPostpone';
40 import {
41 getCacheSignal,
42 getCacheForType,
@@ -100,6 +101,7 @@ export {
101 lazy,
102 memo,
103 cache,
104 + postpone as unstable_postpone,
105 useCallback,
106 useContext,
107 useEffect,
packages/react/src/ReactPostpone.js new
+23
@@ -0,0 +1,23 @@
1 +/**
2 + * Copyright (c) Meta Platforms, Inc. and its affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + *
7 + * @flow
8 + */
9 +
10 +import {REACT_POSTPONE_TYPE} from 'shared/ReactSymbols';
11 +
12 +declare class Postpone extends Error {
13 + $$typeof: symbol;
14 +}
15 +
16 +export type {Postpone};
17 +
18 +export function postpone(reason: string): void {
19 + // eslint-disable-next-line react-internal/prod-error-codes
20 + const postponeInstance: Postpone = (new Error(reason): any);
21 + postponeInstance.$$typeof = REACT_POSTPONE_TYPE;
22 + throw postponeInstance;
23 +}
packages/react/src/ReactSharedSubset.experimental.js
+1
@@ -32,6 +32,7 @@ export {
32 unstable_SuspenseList,
33 unstable_getCacheSignal,
34 unstable_getCacheForType,
35 + unstable_postpone,
36 useId,
37 useCallback,
38 useContext,
packages/shared/ReactFeatureFlags.js
+2
@@ -86,6 +86,8 @@ export const enableFormActions = __EXPERIMENTAL__;
86
87 export const enableBinaryFlight = __EXPERIMENTAL__;
88
89 +export const enablePostpone = __EXPERIMENTAL__;
90 +
91 export const enableTransitionTracing = false;
92
93 // No known bugs, but needs performance testing
packages/shared/ReactSymbols.js
+2
@@ -49,6 +49,8 @@ export const REACT_MEMO_CACHE_SENTINEL: symbol = Symbol.for(
49 'react.memo_cache_sentinel',
50 );
51
52 +export const REACT_POSTPONE_TYPE: symbol = Symbol.for('react.postpone');
53 +
54 const MAYBE_ITERATOR_SYMBOL = Symbol.iterator;
55 const FAUX_ITERATOR_SYMBOL = '@@iterator';
56
packages/shared/forks/ReactFeatureFlags.native-fb.js
+1
@@ -37,6 +37,7 @@ export const enableCacheElement = true;
37 export const enableFetchInstrumentation = false;
38 export const enableFormActions = true; // Doesn't affect Native
39 export const enableBinaryFlight = true;
40 +export const enablePostpone = false;
41 export const enableSchedulerDebugging = false;
42 export const debugRenderPhaseSideEffectsForStrictMode = true;
43 export const disableJavaScriptURLs = false;
packages/shared/forks/ReactFeatureFlags.native-oss.js
+1
@@ -25,6 +25,7 @@ export const enableCacheElement = false;
25 export const enableFetchInstrumentation = false;
26 export const enableFormActions = true; // Doesn't affect Native
27 export const enableBinaryFlight = true;
28 +export const enablePostpone = false;
29 export const disableJavaScriptURLs = false;
30 export const disableCommentsAsDOMContainers = true;
31 export const disableInputAttributeSyncing = false;
packages/shared/forks/ReactFeatureFlags.test-renderer.js
+1
@@ -25,6 +25,7 @@ export const enableCacheElement = __EXPERIMENTAL__;
25 export const enableFetchInstrumentation = true;
26 export const enableFormActions = true; // Doesn't affect Test Renderer
27 export const enableBinaryFlight = true;
28 +export const enablePostpone = false;
29 export const disableJavaScriptURLs = false;
30 export const disableCommentsAsDOMContainers = true;
31 export const disableInputAttributeSyncing = false;
packages/shared/forks/ReactFeatureFlags.test-renderer.native.js
+1
@@ -25,6 +25,7 @@ export const enableCacheElement = true;
25 export const enableFetchInstrumentation = false;
26 export const enableFormActions = true; // Doesn't affect Test Renderer
27 export const enableBinaryFlight = true;
28 +export const enablePostpone = false;
29 export const disableJavaScriptURLs = false;
30 export const disableCommentsAsDOMContainers = true;
31 export const disableInputAttributeSyncing = false;
packages/shared/forks/ReactFeatureFlags.test-renderer.www.js
+1
@@ -25,6 +25,7 @@ export const enableCacheElement = true;
25 export const enableFetchInstrumentation = false;
26 export const enableFormActions = true; // Doesn't affect Test Renderer
27 export const enableBinaryFlight = true;
28 +export const enablePostpone = false;
29 export const enableSchedulerDebugging = false;
30 export const disableJavaScriptURLs = false;
31 export const disableCommentsAsDOMContainers = true;
packages/shared/forks/ReactFeatureFlags.www.js
+2
@@ -77,6 +77,8 @@ export const enableFormActions = false;
77
78 export const enableBinaryFlight = true;
79
80 +export const enablePostpone = false;
81 +
82 export const disableJavaScriptURLs = true;
83
84 // TODO: www currently relies on this feature. It's disabled in open source.
scripts/error-codes/codes.json
+3 -2
@@ -468,5 +468,6 @@
468 "480": "File/Blob fields are not yet supported in progressive forms. It probably means you are closing over binary data or FormData in a Server Action.",
469 "481": "Tried to encode a Server Action from a different instance than the encoder is from. This is a bug in React.",
470 "482": "async/await is not yet supported in Client Components, only Server Components. This error is often caused by accidentally adding `'use client'` to a module that was originally written for the server.",
471 - "483": "Hooks are not supported inside an async component. This error is often caused by accidentally adding `'use client'` to a module that was originally written for the server."
472 -}
471 + "483": "Hooks are not supported inside an async component. This error is often caused by accidentally adding `'use client'` to a module that was originally written for the server.",
472 + "484": "A Server Component was postponed. The reason is omitted in production builds to avoid leaking sensitive details."
473 +}
\ No newline at end of file