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.
This commit is contained in:
Sebastian Markbåge
2023-08-17 13:26:14 -04:00
committed by GitHub
parent 87c6735094
commit 3bdc4aba5b
34 changed files with 666 additions and 235 deletions
+77 -3
View File
@@ -21,7 +21,9 @@ import type {HintModel} from 'react-server/src/ReactFlightServerConfig';
import type {CallServerCallback} from './ReactFlightReplyClient';
import {enableBinaryFlight} from 'shared/ReactFeatureFlags';
import type {Postpone} from 'react/src/ReactPostpone';
import {enableBinaryFlight, enablePostpone} from 'shared/ReactFeatureFlags';
import {
resolveClientReference,
@@ -39,7 +41,11 @@ import {
knownServerReferences,
} from './ReactFlightReplyClient';
import {REACT_LAZY_TYPE, REACT_ELEMENT_TYPE} from 'shared/ReactSymbols';
import {
REACT_LAZY_TYPE,
REACT_ELEMENT_TYPE,
REACT_POSTPONE_TYPE,
} from 'shared/ReactSymbols';
import {getOrCreateServerContext} from 'shared/ReactServerContextRegistry';
@@ -226,7 +232,7 @@ function createBlockedChunk<T>(response: Response): BlockedChunk<T> {
function createErrorChunk<T>(
response: Response,
error: ErrorWithDigest,
error: Error | Postpone,
): ErroredChunk<T> {
// $FlowFixMe[invalid-constructor] Flow doesn't support functions as constructors
return new Chunk(ERRORED, null, error, response);
@@ -867,6 +873,57 @@ function resolveErrorDev(
}
}
function resolvePostponeProd(response: Response, id: number): void {
if (__DEV__) {
// These errors should never make it into a build so we don't need to encode them in codes.json
// eslint-disable-next-line react-internal/prod-error-codes
throw new Error(
'resolvePostponeProd should never be called in development mode. Use resolvePostponeDev instead. This is a bug in React.',
);
}
const error = new Error(
'A Server Component was postponed. The reason is omitted in production' +
' builds to avoid leaking sensitive details.',
);
const postponeInstance: Postpone = (error: any);
postponeInstance.$$typeof = REACT_POSTPONE_TYPE;
postponeInstance.stack = 'Error: ' + error.message;
const chunks = response._chunks;
const chunk = chunks.get(id);
if (!chunk) {
chunks.set(id, createErrorChunk(response, postponeInstance));
} else {
triggerErrorOnChunk(chunk, postponeInstance);
}
}
function resolvePostponeDev(
response: Response,
id: number,
reason: string,
stack: string,
): void {
if (!__DEV__) {
// These errors should never make it into a build so we don't need to encode them in codes.json
// eslint-disable-next-line react-internal/prod-error-codes
throw new Error(
'resolvePostponeDev should never be called in production mode. Use resolvePostponeProd instead. This is a bug in React.',
);
}
// eslint-disable-next-line react-internal/prod-error-codes
const error = new Error(reason || '');
const postponeInstance: Postpone = (error: any);
postponeInstance.$$typeof = REACT_POSTPONE_TYPE;
postponeInstance.stack = stack;
const chunks = response._chunks;
const chunk = chunks.get(id);
if (!chunk) {
chunks.set(id, createErrorChunk(response, postponeInstance));
} else {
triggerErrorOnChunk(chunk, postponeInstance);
}
}
function resolveHint(
response: Response,
code: string,
@@ -1019,6 +1076,23 @@ function processFullRow(
resolveText(response, id, row);
return;
}
case 80 /* "P" */: {
if (enablePostpone) {
if (__DEV__) {
const postponeInfo = JSON.parse(row);
resolvePostponeDev(
response,
id,
postponeInfo.reason,
postponeInfo.stack,
);
} else {
resolvePostponeProd(response, id);
}
return;
}
}
// Fallthrough
default: /* """ "{" "[" "t" "f" "n" "0" - "9" */ {
// We assume anything else is JSON.
resolveModel(response, id, row);
@@ -6040,4 +6040,95 @@ describe('ReactDOMFizzServer', () => {
console.error = originalConsoleError;
}
});
// @gate enablePostpone
it('client renders postponed boundaries without erroring', async () => {
function Postponed({isClient}) {
if (!isClient) {
React.unstable_postpone('testing postpone');
}
return 'client only';
}
function App({isClient}) {
return (
<div>
<Suspense fallback={'loading...'}>
<Postponed isClient={isClient} />
</Suspense>
</div>
);
}
const errors = [];
await act(() => {
const {pipe} = renderToPipeableStream(<App isClient={false} />, {
onError(error) {
errors.push(error.message);
},
});
pipe(writable);
});
expect(getVisibleChildren(container)).toEqual(<div>loading...</div>);
ReactDOMClient.hydrateRoot(container, <App isClient={true} />, {
onRecoverableError(error) {
errors.push(error.message);
},
});
await waitForAll([]);
// Postponing should not be logged as a recoverable error since it's intentional.
expect(errors).toEqual([]);
expect(getVisibleChildren(container)).toEqual(<div>client only</div>);
});
// @gate enablePostpone
it('errors if trying to postpone outside a Suspense boundary', async () => {
function Postponed() {
React.unstable_postpone('testing postpone');
return 'client only';
}
function App() {
return (
<div>
<Postponed />
</div>
);
}
const errors = [];
const fatalErrors = [];
const postponed = [];
let written = false;
const testWritable = new Stream.Writable();
testWritable._write = (chunk, encoding, next) => {
written = true;
};
await act(() => {
const {pipe} = renderToPipeableStream(<App />, {
onPostpone(reason) {
postponed.push(reason);
},
onError(error) {
errors.push(error.message);
},
onShellError(error) {
fatalErrors.push(error.message);
},
});
pipe(testWritable);
});
expect(written).toBe(false);
// Postponing is not logged as an error but as a postponed reason.
expect(errors).toEqual([]);
expect(postponed).toEqual(['testing postpone']);
// However, it does error the shell.
expect(fatalErrors).toEqual(['testing postpone']);
});
});
@@ -503,4 +503,43 @@ describe('ReactDOMFizzServerBrowser', () => {
`"<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>"`,
);
});
// @gate enablePostpone
it('errors if trying to postpone outside a Suspense boundary', async () => {
function Postponed() {
React.unstable_postpone('testing postpone');
return 'client only';
}
function App() {
return (
<div>
<Postponed />
</div>
);
}
const errors = [];
const postponed = [];
let caughtError = null;
try {
await ReactDOMFizzServer.renderToReadableStream(<App />, {
onError(error) {
errors.push(error.message);
},
onPostpone(reason) {
postponed.push(reason);
},
});
} catch (error) {
caughtError = error;
}
// Postponing is not logged as an error but as a postponed reason.
expect(errors).toEqual([]);
expect(postponed).toEqual(['testing postpone']);
// However, it does error the shell.
expect(caughtError.message).toEqual('testing postpone');
});
});
@@ -35,6 +35,7 @@ type Options = {
progressiveChunkSize?: number,
signal?: AbortSignal,
onError?: (error: mixed) => ?string,
onPostpone?: (reason: string) => void,
unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
};
@@ -100,6 +101,7 @@ function renderToReadableStream(
onShellReady,
onShellError,
onFatalError,
options ? options.onPostpone : undefined,
);
if (options && options.signal) {
const signal = options.signal;
@@ -35,6 +35,7 @@ type Options = {
progressiveChunkSize?: number,
signal?: AbortSignal,
onError?: (error: mixed) => ?string,
onPostpone?: (reason: string) => void,
unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
};
@@ -101,6 +102,7 @@ function renderToReadableStream(
onShellReady,
onShellError,
onFatalError,
options ? options.onPostpone : undefined,
);
if (options && options.signal) {
const signal = options.signal;
@@ -35,6 +35,7 @@ type Options = {
progressiveChunkSize?: number,
signal?: AbortSignal,
onError?: (error: mixed) => ?string,
onPostpone?: (reason: string) => void,
unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
};
@@ -100,6 +101,7 @@ function renderToReadableStream(
onShellReady,
onShellError,
onFatalError,
options ? options.onPostpone : undefined,
);
if (options && options.signal) {
const signal = options.signal;
@@ -49,6 +49,7 @@ type Options = {
onShellError?: (error: mixed) => void,
onAllReady?: () => void,
onError?: (error: mixed) => ?string,
onPostpone?: (reason: string) => void,
unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
};
@@ -80,6 +81,7 @@ function createRequestImpl(children: ReactNodeList, options: void | Options) {
options ? options.onShellReady : undefined,
options ? options.onShellError : undefined,
undefined,
options ? options.onPostpone : undefined,
);
}
@@ -34,6 +34,7 @@ type Options = {
progressiveChunkSize?: number,
signal?: AbortSignal,
onError?: (error: mixed) => ?string,
onPostpone?: (reason: string) => void,
unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
};
@@ -85,6 +86,7 @@ function prerender(
undefined,
undefined,
onFatalError,
options ? options.onPostpone : undefined,
);
if (options && options.signal) {
const signal = options.signal;
@@ -34,6 +34,7 @@ type Options = {
progressiveChunkSize?: number,
signal?: AbortSignal,
onError?: (error: mixed) => ?string,
onPostpone?: (reason: string) => void,
unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
};
@@ -85,6 +86,7 @@ function prerender(
undefined,
undefined,
onFatalError,
options ? options.onPostpone : undefined,
);
if (options && options.signal) {
const signal = options.signal;
@@ -36,6 +36,7 @@ type Options = {
progressiveChunkSize?: number,
signal?: AbortSignal,
onError?: (error: mixed) => ?string,
onPostpone?: (reason: string) => void,
unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
};
@@ -99,6 +100,7 @@ function prerenderToNodeStreams(
undefined,
undefined,
onFatalError,
options ? options.onPostpone : undefined,
);
if (options && options.signal) {
const signal = options.signal;
@@ -79,6 +79,7 @@ function renderToStringImpl(
onShellReady,
undefined,
undefined,
undefined,
);
startWork(request);
// If anything suspended and is still pending, we'll abort it before writing.
@@ -86,6 +86,7 @@ function renderToNodeStreamImpl(
onAllReady,
undefined,
undefined,
undefined,
);
destination.request = request;
startWork(request);
+19 -13
View File
@@ -109,6 +109,7 @@ import {
enableHostSingletons,
enableFormActions,
enableAsyncActions,
enablePostpone,
} from 'shared/ReactFeatureFlags';
import isArray from 'shared/isArray';
import shallowEqual from 'shared/shallowEqual';
@@ -2859,7 +2860,8 @@ function updateDehydratedSuspenseComponent(
// This boundary is in a permanent fallback state. In this case, we'll never
// get an update and we'll never be able to hydrate the final content. Let's just try the
// client side render instead.
let digest, message, stack;
let digest: ?string;
let message, stack;
if (__DEV__) {
({digest, message, stack} =
getSuspenseInstanceFallbackErrorDetails(suspenseInstance));
@@ -2867,19 +2869,23 @@ function updateDehydratedSuspenseComponent(
({digest} = getSuspenseInstanceFallbackErrorDetails(suspenseInstance));
}
let error;
if (message) {
// eslint-disable-next-line react-internal/prod-error-codes
error = new Error(message);
} else {
error = new Error(
'The server could not finish this Suspense boundary, likely ' +
'due to an error during server rendering. Switched to ' +
'client rendering.',
);
let capturedValue = null;
// TODO: Figure out a better signal than encoding a magic digest value.
if (!enablePostpone || digest !== 'POSTPONE') {
let error;
if (message) {
// eslint-disable-next-line react-internal/prod-error-codes
error = new Error(message);
} else {
error = new Error(
'The server could not finish this Suspense boundary, likely ' +
'due to an error during server rendering. Switched to ' +
'client rendering.',
);
}
(error: any).digest = digest;
capturedValue = createCapturedValue<mixed>(error, digest, stack);
}
(error: any).digest = digest;
const capturedValue = createCapturedValue<mixed>(error, digest, stack);
return retrySuspenseComponentWithoutHydrating(
current,
workInProgress,
+182 -177
View File
@@ -41,6 +41,7 @@ import {
enableDebugTracing,
enableLazyContextPropagation,
enableUpdaterTracking,
enablePostpone,
} from 'shared/ReactFeatureFlags';
import {createCapturedValueAtFiber} from './ReactCapturedValue';
import {
@@ -82,6 +83,7 @@ import {
} from './ReactFiberHydrationContext';
import {ConcurrentRoot} from './ReactRootTags';
import {noopSuspenseyCommitThenable} from './ReactFiberThenable';
import {REACT_POSTPONE_TYPE} from 'shared/ReactSymbols';
function createRootErrorUpdate(
fiber: Fiber,
@@ -332,210 +334,213 @@ function throwException(
}
}
if (
value !== null &&
typeof value === 'object' &&
typeof value.then === 'function'
) {
// This is a wakeable. The component suspended.
const wakeable: Wakeable = (value: any);
resetSuspendedComponent(sourceFiber, rootRenderLanes);
if (__DEV__) {
if (getIsHydrating() && sourceFiber.mode & ConcurrentMode) {
markDidThrowWhileHydratingDEV();
}
if (value !== null && typeof value === 'object') {
if (enablePostpone && value.$$typeof === REACT_POSTPONE_TYPE) {
// Act as if this is an infinitely suspending promise.
value = {then: function () {}};
}
if (typeof value.then === 'function') {
// This is a wakeable. The component suspended.
const wakeable: Wakeable = (value: any);
resetSuspendedComponent(sourceFiber, rootRenderLanes);
if (__DEV__) {
if (enableDebugTracing) {
if (sourceFiber.mode & DebugTracingMode) {
const name = getComponentNameFromFiber(sourceFiber) || 'Unknown';
logComponentSuspended(name, wakeable);
if (__DEV__) {
if (getIsHydrating() && sourceFiber.mode & ConcurrentMode) {
markDidThrowWhileHydratingDEV();
}
}
}
// Mark the nearest Suspense boundary to switch to rendering a fallback.
const suspenseBoundary = getSuspenseHandler();
if (suspenseBoundary !== null) {
switch (suspenseBoundary.tag) {
case SuspenseComponent: {
// If this suspense boundary is not already showing a fallback, mark
// the in-progress render as suspended. We try to perform this logic
// as soon as soon as possible during the render phase, so the work
// loop can know things like whether it's OK to switch to other tasks,
// or whether it can wait for data to resolve before continuing.
// TODO: Most of these checks are already performed when entering a
// Suspense boundary. We should track the information on the stack so
// we don't have to recompute it on demand. This would also allow us
// to unify with `use` which needs to perform this logic even sooner,
// before `throwException` is called.
if (sourceFiber.mode & ConcurrentMode) {
if (getShellBoundary() === null) {
// Suspended in the "shell" of the app. This is an undesirable
// loading state. We should avoid committing this tree.
renderDidSuspendDelayIfPossible();
} else {
// If we suspended deeper than the shell, we don't need to delay
// the commmit. However, we still call renderDidSuspend if this is
// a new boundary, to tell the work loop that a new fallback has
// appeared during this render.
// TODO: Theoretically we should be able to delete this branch.
// It's currently used for two things: 1) to throttle the
// appearance of successive loading states, and 2) in
// SuspenseList, to determine whether the children include any
// pending fallbacks. For 1, we should apply throttling to all
// retries, not just ones that render an additional fallback. For
// 2, we should check subtreeFlags instead. Then we can delete
// this branch.
const current = suspenseBoundary.alternate;
if (current === null) {
renderDidSuspend();
if (__DEV__) {
if (enableDebugTracing) {
if (sourceFiber.mode & DebugTracingMode) {
const name = getComponentNameFromFiber(sourceFiber) || 'Unknown';
logComponentSuspended(name, wakeable);
}
}
}
// Mark the nearest Suspense boundary to switch to rendering a fallback.
const suspenseBoundary = getSuspenseHandler();
if (suspenseBoundary !== null) {
switch (suspenseBoundary.tag) {
case SuspenseComponent: {
// If this suspense boundary is not already showing a fallback, mark
// the in-progress render as suspended. We try to perform this logic
// as soon as soon as possible during the render phase, so the work
// loop can know things like whether it's OK to switch to other tasks,
// or whether it can wait for data to resolve before continuing.
// TODO: Most of these checks are already performed when entering a
// Suspense boundary. We should track the information on the stack so
// we don't have to recompute it on demand. This would also allow us
// to unify with `use` which needs to perform this logic even sooner,
// before `throwException` is called.
if (sourceFiber.mode & ConcurrentMode) {
if (getShellBoundary() === null) {
// Suspended in the "shell" of the app. This is an undesirable
// loading state. We should avoid committing this tree.
renderDidSuspendDelayIfPossible();
} else {
// If we suspended deeper than the shell, we don't need to delay
// the commmit. However, we still call renderDidSuspend if this is
// a new boundary, to tell the work loop that a new fallback has
// appeared during this render.
// TODO: Theoretically we should be able to delete this branch.
// It's currently used for two things: 1) to throttle the
// appearance of successive loading states, and 2) in
// SuspenseList, to determine whether the children include any
// pending fallbacks. For 1, we should apply throttling to all
// retries, not just ones that render an additional fallback. For
// 2, we should check subtreeFlags instead. Then we can delete
// this branch.
const current = suspenseBoundary.alternate;
if (current === null) {
renderDidSuspend();
}
}
}
}
suspenseBoundary.flags &= ~ForceClientRender;
markSuspenseBoundaryShouldCapture(
suspenseBoundary,
returnFiber,
sourceFiber,
root,
rootRenderLanes,
);
// Retry listener
//
// If the fallback does commit, we need to attach a different type of
// listener. This one schedules an update on the Suspense boundary to
// turn the fallback state off.
//
// Stash the wakeable on the boundary fiber so we can access it in the
// commit phase.
//
// When the wakeable resolves, we'll attempt to render the boundary
// again ("retry").
suspenseBoundary.flags &= ~ForceClientRender;
markSuspenseBoundaryShouldCapture(
suspenseBoundary,
returnFiber,
sourceFiber,
root,
rootRenderLanes,
);
// Retry listener
//
// If the fallback does commit, we need to attach a different type of
// listener. This one schedules an update on the Suspense boundary to
// turn the fallback state off.
//
// Stash the wakeable on the boundary fiber so we can access it in the
// commit phase.
//
// When the wakeable resolves, we'll attempt to render the boundary
// again ("retry").
// Check if this is a Suspensey resource. We do not attach retry
// listeners to these, because we don't actually need them for
// rendering. Only for committing. Instead, if a fallback commits
// and the only thing that suspended was a Suspensey resource, we
// retry immediately.
// TODO: Refactor throwException so that we don't have to do this type
// check. The caller already knows what the cause was.
const isSuspenseyResource = wakeable === noopSuspenseyCommitThenable;
if (isSuspenseyResource) {
suspenseBoundary.flags |= ScheduleRetry;
} else {
const retryQueue: RetryQueue | null =
(suspenseBoundary.updateQueue: any);
if (retryQueue === null) {
suspenseBoundary.updateQueue = new Set([wakeable]);
} else {
retryQueue.add(wakeable);
}
}
break;
}
case OffscreenComponent: {
if (suspenseBoundary.mode & ConcurrentMode) {
suspenseBoundary.flags |= ShouldCapture;
// Check if this is a Suspensey resource. We do not attach retry
// listeners to these, because we don't actually need them for
// rendering. Only for committing. Instead, if a fallback commits
// and the only thing that suspended was a Suspensey resource, we
// retry immediately.
// TODO: Refactor throwException so that we don't have to do this type
// check. The caller already knows what the cause was.
const isSuspenseyResource =
wakeable === noopSuspenseyCommitThenable;
if (isSuspenseyResource) {
suspenseBoundary.flags |= ScheduleRetry;
} else {
const offscreenQueue: OffscreenQueue | null =
const retryQueue: RetryQueue | null =
(suspenseBoundary.updateQueue: any);
if (offscreenQueue === null) {
const newOffscreenQueue: OffscreenQueue = {
transitions: null,
markerInstances: null,
retryQueue: new Set([wakeable]),
};
suspenseBoundary.updateQueue = newOffscreenQueue;
if (retryQueue === null) {
suspenseBoundary.updateQueue = new Set([wakeable]);
} else {
const retryQueue = offscreenQueue.retryQueue;
if (retryQueue === null) {
offscreenQueue.retryQueue = new Set([wakeable]);
} else {
retryQueue.add(wakeable);
}
retryQueue.add(wakeable);
}
}
break;
}
// Fall through
case OffscreenComponent: {
if (suspenseBoundary.mode & ConcurrentMode) {
suspenseBoundary.flags |= ShouldCapture;
const isSuspenseyResource =
wakeable === noopSuspenseyCommitThenable;
if (isSuspenseyResource) {
suspenseBoundary.flags |= ScheduleRetry;
} else {
const offscreenQueue: OffscreenQueue | null =
(suspenseBoundary.updateQueue: any);
if (offscreenQueue === null) {
const newOffscreenQueue: OffscreenQueue = {
transitions: null,
markerInstances: null,
retryQueue: new Set([wakeable]),
};
suspenseBoundary.updateQueue = newOffscreenQueue;
} else {
const retryQueue = offscreenQueue.retryQueue;
if (retryQueue === null) {
offscreenQueue.retryQueue = new Set([wakeable]);
} else {
retryQueue.add(wakeable);
}
}
}
break;
}
// Fall through
}
default: {
throw new Error(
`Unexpected Suspense handler tag (${suspenseBoundary.tag}). This ` +
'is a bug in React.',
);
}
}
default: {
throw new Error(
`Unexpected Suspense handler tag (${suspenseBoundary.tag}). This ` +
'is a bug in React.',
);
// We only attach ping listeners in concurrent mode. Legacy Suspense always
// commits fallbacks synchronously, so there are no pings.
if (suspenseBoundary.mode & ConcurrentMode) {
attachPingListener(root, wakeable, rootRenderLanes);
}
}
// We only attach ping listeners in concurrent mode. Legacy Suspense always
// commits fallbacks synchronously, so there are no pings.
if (suspenseBoundary.mode & ConcurrentMode) {
attachPingListener(root, wakeable, rootRenderLanes);
}
return;
} else {
// No boundary was found. Unless this is a sync update, this is OK.
// We can suspend and wait for more data to arrive.
if (root.tag === ConcurrentRoot) {
// In a concurrent root, suspending without a Suspense boundary is
// allowed. It will suspend indefinitely without committing.
//
// TODO: Should we have different behavior for discrete updates? What
// about flushSync? Maybe it should put the tree into an inert state,
// and potentially log a warning. Revisit this for a future release.
attachPingListener(root, wakeable, rootRenderLanes);
renderDidSuspendDelayIfPossible();
return;
} else {
// In a legacy root, suspending without a boundary is always an error.
const uncaughtSuspenseError = new Error(
'A component suspended while responding to synchronous input. This ' +
'will cause the UI to be replaced with a loading indicator. To ' +
'fix, updates that suspend should be wrapped ' +
'with startTransition.',
);
value = uncaughtSuspenseError;
// No boundary was found. Unless this is a sync update, this is OK.
// We can suspend and wait for more data to arrive.
if (root.tag === ConcurrentRoot) {
// In a concurrent root, suspending without a Suspense boundary is
// allowed. It will suspend indefinitely without committing.
//
// TODO: Should we have different behavior for discrete updates? What
// about flushSync? Maybe it should put the tree into an inert state,
// and potentially log a warning. Revisit this for a future release.
attachPingListener(root, wakeable, rootRenderLanes);
renderDidSuspendDelayIfPossible();
return;
} else {
// In a legacy root, suspending without a boundary is always an error.
const uncaughtSuspenseError = new Error(
'A component suspended while responding to synchronous input. This ' +
'will cause the UI to be replaced with a loading indicator. To ' +
'fix, updates that suspend should be wrapped ' +
'with startTransition.',
);
value = uncaughtSuspenseError;
}
}
}
}
// This is a regular error, not a Suspense wakeable.
if (getIsHydrating() && sourceFiber.mode & ConcurrentMode) {
markDidThrowWhileHydratingDEV();
const suspenseBoundary = getSuspenseHandler();
// If the error was thrown during hydration, we may be able to recover by
// discarding the dehydrated content and switching to a client render.
// Instead of surfacing the error, find the nearest Suspense boundary
// and render it again without hydration.
if (suspenseBoundary !== null) {
if ((suspenseBoundary.flags & ShouldCapture) === NoFlags) {
// Set a flag to indicate that we should try rendering the normal
// children again, not the fallback.
suspenseBoundary.flags |= ForceClientRender;
}
markSuspenseBoundaryShouldCapture(
suspenseBoundary,
returnFiber,
sourceFiber,
root,
rootRenderLanes,
);
// Even though the user may not be affected by this error, we should
// still log it so it can be fixed.
queueHydrationError(createCapturedValueAtFiber(value, sourceFiber));
return;
}
} else {
// This is a regular error, not a Suspense wakeable.
if (getIsHydrating() && sourceFiber.mode & ConcurrentMode) {
markDidThrowWhileHydratingDEV();
const suspenseBoundary = getSuspenseHandler();
// If the error was thrown during hydration, we may be able to recover by
// discarding the dehydrated content and switching to a client render.
// Instead of surfacing the error, find the nearest Suspense boundary
// and render it again without hydration.
if (suspenseBoundary !== null) {
if ((suspenseBoundary.flags & ShouldCapture) === NoFlags) {
// Set a flag to indicate that we should try rendering the normal
// children again, not the fallback.
suspenseBoundary.flags |= ForceClientRender;
}
markSuspenseBoundaryShouldCapture(
suspenseBoundary,
returnFiber,
sourceFiber,
root,
rootRenderLanes,
);
// Even though the user may not be affected by this error, we should
// still log it so it can be fixed.
queueHydrationError(createCapturedValueAtFiber(value, sourceFiber));
return;
}
} else {
// Otherwise, fall through to the error path.
}
// Otherwise, fall through to the error path.
}
value = createCapturedValueAtFiber(value, sourceFiber);
@@ -49,6 +49,7 @@ function createDrainHandler(destination: Destination, request: Request) {
type Options = {
onError?: (error: mixed) => void,
onPostpone?: (reason: string) => void,
context?: Array<[string, ServerContextJSONValue]>,
identifierPrefix?: string,
};
@@ -69,6 +70,7 @@ function renderToPipeableStream(
options ? options.onError : undefined,
options ? options.context : undefined,
options ? options.identifierPrefix : undefined,
options ? options.onPostpone : undefined,
);
let hasStartedFlowing = false;
startWork(request);
@@ -38,6 +38,7 @@ type Options = {
signal?: AbortSignal,
context?: Array<[string, ServerContextJSONValue]>,
onError?: (error: mixed) => void,
onPostpone?: (reason: string) => void,
};
function renderToReadableStream(
@@ -51,6 +52,7 @@ function renderToReadableStream(
options ? options.onError : undefined,
options ? options.context : undefined,
options ? options.identifierPrefix : undefined,
options ? options.onPostpone : undefined,
);
if (options && options.signal) {
const signal = options.signal;
@@ -38,6 +38,7 @@ type Options = {
signal?: AbortSignal,
context?: Array<[string, ServerContextJSONValue]>,
onError?: (error: mixed) => void,
onPostpone?: (reason: string) => void,
};
function renderToReadableStream(
@@ -51,6 +52,7 @@ function renderToReadableStream(
options ? options.onError : undefined,
options ? options.context : undefined,
options ? options.identifierPrefix : undefined,
options ? options.onPostpone : undefined,
);
if (options && options.signal) {
const signal = options.signal;
@@ -50,6 +50,7 @@ function createDrainHandler(destination: Destination, request: Request) {
type Options = {
onError?: (error: mixed) => void,
onPostpone?: (reason: string) => void,
context?: Array<[string, ServerContextJSONValue]>,
identifierPrefix?: string,
};
@@ -70,6 +71,7 @@ function renderToPipeableStream(
options ? options.onError : undefined,
options ? options.context : undefined,
options ? options.identifierPrefix : undefined,
options ? options.onPostpone : undefined,
);
let hasStartedFlowing = false;
startWork(request);
@@ -1178,4 +1178,48 @@ describe('ReactFlightDOMBrowser', () => {
'</head><body><p>hello world</p></body></html>',
);
});
// @gate enablePostpone
it('supports postpone in Server Components', async () => {
function Server() {
React.unstable_postpone('testing postpone');
return 'Not shown';
}
let postponed = null;
const stream = ReactServerDOMServer.renderToReadableStream(
<Suspense fallback="Loading...">
<Server />
</Suspense>,
null,
{
onPostpone(reason) {
postponed = reason;
},
},
);
const response = ReactServerDOMClient.createFromReadableStream(stream);
function Client() {
return use(response);
}
const container = document.createElement('div');
const root = ReactDOMClient.createRoot(container);
await act(async () => {
root.render(
<div>
Shell: <Client />
</div>,
);
});
// We should have reserved the shell already. Which means that the Server
// Component should've been a lazy component.
expect(container.innerHTML).toContain('Shell:');
expect(container.innerHTML).toContain('Loading...');
expect(container.innerHTML).not.toContain('Not shown');
expect(postponed).toBe('testing postpone');
});
});
+43 -2
View File
@@ -128,6 +128,7 @@ import {
REACT_SERVER_CONTEXT_TYPE,
REACT_SCOPE_TYPE,
REACT_OFFSCREEN_TYPE,
REACT_POSTPONE_TYPE,
} from 'shared/ReactSymbols';
import ReactSharedInternals from 'shared/ReactSharedInternals';
import {
@@ -137,12 +138,14 @@ import {
enableSuspenseAvoidThisFallbackFizz,
enableFloat,
enableCache,
enablePostpone,
} from 'shared/ReactFeatureFlags';
import assign from 'shared/assign';
import getComponentNameFromType from 'shared/getComponentNameFromType';
import isArray from 'shared/isArray';
import {SuspenseException, getSuspendedThenable} from './ReactFizzThenable';
import type {Postpone} from 'react/src/ReactPostpone';
const ReactCurrentDispatcher = ReactSharedInternals.ReactCurrentDispatcher;
const ReactCurrentCache = ReactSharedInternals.ReactCurrentCache;
@@ -241,6 +244,9 @@ export opaque type Request = {
// emit a different response to the stream instead.
onShellError: (error: mixed) => void,
onFatalError: (error: mixed) => void,
// onPostpone is called when postpone() is called anywhere in the tree, which will defer
// rendering - e.g. to the client. This is considered intentional and not an error.
onPostpone: (reason: string) => void,
};
// This is a default heuristic for how to split up the HTML content into progressive
@@ -278,6 +284,7 @@ export function createRequest(
onShellReady: void | (() => void),
onShellError: void | ((error: mixed) => void),
onFatalError: void | ((error: mixed) => void),
onPostpone: void | ((reason: string) => void),
): Request {
prepareHostDispatcher();
const pingedTasks: Array<Task> = [];
@@ -303,6 +310,7 @@ export function createRequest(
completedBoundaries: ([]: Array<SuspenseBoundary>),
partialBoundaries: ([]: Array<SuspenseBoundary>),
onError: onError === undefined ? defaultErrorHandler : onError,
onPostpone: onPostpone === undefined ? noop : onPostpone,
onAllReady: onAllReady === undefined ? noop : onAllReady,
onShellReady: onShellReady === undefined ? noop : onShellReady,
onShellError: onShellError === undefined ? noop : onShellError,
@@ -508,6 +516,12 @@ function captureBoundaryErrorDetailsDev(
}
}
function logPostpone(request: Request, reason: string): void {
// If this callback errors, we intentionally let that error bubble up to become a fatal error
// so that someone fixes the error reporting instead of hiding it.
request.onPostpone(reason);
}
function logRecoverableError(request: Request, error: any): ?string {
// If this callback errors, we intentionally let that error bubble up to become a fatal error
// so that someone fixes the error reporting instead of hiding it.
@@ -622,7 +636,21 @@ function renderSuspenseBoundary(
} catch (error) {
contentRootSegment.status = ERRORED;
newBoundary.forceClientRender = true;
newBoundary.errorDigest = logRecoverableError(request, error);
let errorDigest;
if (
enablePostpone &&
typeof error === 'object' &&
error !== null &&
error.$$typeof === REACT_POSTPONE_TYPE
) {
const postponeInstance: Postpone = (error: any);
logPostpone(request, postponeInstance.message);
// TODO: Figure out a better signal than a magic digest value.
errorDigest = 'POSTPONE';
} else {
errorDigest = logRecoverableError(request, error);
}
newBoundary.errorDigest = errorDigest;
if (__DEV__) {
captureBoundaryErrorDetailsDev(newBoundary, error);
}
@@ -1678,7 +1706,20 @@ function erroredTask(
error: mixed,
) {
// Report the error to a global handler.
const errorDigest = logRecoverableError(request, error);
let errorDigest;
if (
enablePostpone &&
typeof error === 'object' &&
error !== null &&
error.$$typeof === REACT_POSTPONE_TYPE
) {
const postponeInstance: Postpone = (error: any);
logPostpone(request, postponeInstance.message);
// TODO: Figure out a better signal than a magic digest value.
errorDigest = 'POSTPONE';
} else {
errorDigest = logRecoverableError(request, error);
}
if (boundary === null) {
fatalError(request, error);
} else {
+106 -38
View File
@@ -9,7 +9,9 @@
import type {Chunk, BinaryChunk, Destination} from './ReactServerStreamConfig';
import {enableBinaryFlight} from 'shared/ReactFeatureFlags';
import type {Postpone} from 'react/src/ReactPostpone';
import {enableBinaryFlight, enablePostpone} from 'shared/ReactFeatureFlags';
import {
scheduleWork,
@@ -87,6 +89,7 @@ import {
REACT_FRAGMENT_TYPE,
REACT_LAZY_TYPE,
REACT_MEMO_TYPE,
REACT_POSTPONE_TYPE,
REACT_PROVIDER_TYPE,
} from 'shared/ReactSymbols';
@@ -189,6 +192,7 @@ export type Request = {
identifierPrefix: string,
identifierCount: number,
onError: (error: mixed) => ?string,
onPostpone: (reason: string) => void,
toJSON: (key: string, value: ReactClientValue) => ReactJSONValue,
};
@@ -200,6 +204,10 @@ function defaultErrorHandler(error: mixed) {
// Don't transform to our wrapper
}
function defaultPostponeHandler(reason: string) {
// Noop
}
const OPEN = 0;
const CLOSING = 1;
const CLOSED = 2;
@@ -210,6 +218,7 @@ export function createRequest(
onError: void | ((error: mixed) => ?string),
context?: Array<[string, ServerContextJSONValue]>,
identifierPrefix?: string,
onPostpone: void | ((reason: string) => void),
): Request {
if (
ReactCurrentCache.current !== null &&
@@ -248,6 +257,7 @@ export function createRequest(
identifierPrefix: identifierPrefix || '',
identifierCount: 1,
onError: onError === undefined ? defaultErrorHandler : onError,
onPostpone: onPostpone === undefined ? defaultPostponeHandler : onPostpone,
// $FlowFixMe[missing-this-annot]
toJSON: function (key: string, value: ReactClientValue): ReactJSONValue {
return resolveModelToJSON(request, this, key, value);
@@ -297,8 +307,19 @@ function serializeThenable(request: Request, thenable: Thenable<any>): number {
}
case 'rejected': {
const x = thenable.reason;
const digest = logRecoverableError(request, x);
emitErrorChunk(request, newTask.id, digest, x);
if (
enablePostpone &&
typeof x === 'object' &&
x !== null &&
(x: any).$$typeof === REACT_POSTPONE_TYPE
) {
const postponeInstance: Postpone = (x: any);
logPostpone(request, postponeInstance.message);
emitPostponeChunk(request, newTask.id, postponeInstance);
} else {
const digest = logRecoverableError(request, x);
emitErrorChunk(request, newTask.id, digest, x);
}
return newTask.id;
}
default: {
@@ -893,30 +914,40 @@ function resolveModelToJSON(
// later, once we deprecate the old API in favor of `use`.
getSuspendedThenable()
: thrownValue;
// $FlowFixMe[method-unbinding]
if (typeof x === 'object' && x !== null && typeof x.then === 'function') {
// Something suspended, we'll need to create a new task and resolve it later.
request.pendingChunks++;
const newTask = createTask(
request,
value,
getActiveContext(),
request.abortableTasks,
);
const ping = newTask.ping;
x.then(ping, ping);
newTask.thenableState = getThenableStateAfterSuspending();
return serializeLazyID(newTask.id);
} else {
// Something errored. We'll still send everything we have up until this point.
// We'll replace this element with a lazy reference that throws on the client
// once it gets rendered.
request.pendingChunks++;
const errorId = request.nextChunkId++;
const digest = logRecoverableError(request, x);
emitErrorChunk(request, errorId, digest, x);
return serializeLazyID(errorId);
if (typeof x === 'object' && x !== null) {
// $FlowFixMe[method-unbinding]
if (typeof x.then === 'function') {
// Something suspended, we'll need to create a new task and resolve it later.
request.pendingChunks++;
const newTask = createTask(
request,
value,
getActiveContext(),
request.abortableTasks,
);
const ping = newTask.ping;
x.then(ping, ping);
newTask.thenableState = getThenableStateAfterSuspending();
return serializeLazyID(newTask.id);
} else if (enablePostpone && x.$$typeof === REACT_POSTPONE_TYPE) {
// Something postponed. We'll still send everything we have up until this point.
// We'll replace this element with a lazy reference that postpones on the client.
const postponeInstance: Postpone = (x: any);
request.pendingChunks++;
const postponeId = request.nextChunkId++;
logPostpone(request, postponeInstance.message);
emitPostponeChunk(request, postponeId, postponeInstance);
return serializeLazyID(postponeId);
}
}
// Something errored. We'll still send everything we have up until this point.
// We'll replace this element with a lazy reference that throws on the client
// once it gets rendered.
request.pendingChunks++;
const errorId = request.nextChunkId++;
const digest = logRecoverableError(request, x);
emitErrorChunk(request, errorId, digest, x);
return serializeLazyID(errorId);
}
}
@@ -1146,6 +1177,11 @@ function resolveModelToJSON(
);
}
function logPostpone(request: Request, reason: string): void {
const onPostpone = request.onPostpone;
onPostpone(reason);
}
function logRecoverableError(request: Request, error: mixed): string {
const onError = request.onError;
const errorDigest = onError(error);
@@ -1169,6 +1205,30 @@ function fatalError(request: Request, error: mixed): void {
}
}
function emitPostponeChunk(
request: Request,
id: number,
postponeInstance: Postpone,
): void {
let row;
if (__DEV__) {
let reason = '';
let stack = '';
try {
// eslint-disable-next-line react-internal/safe-string-coercion
reason = String(postponeInstance.message);
// eslint-disable-next-line react-internal/safe-string-coercion
stack = String(postponeInstance.stack);
} catch (x) {}
row = serializeRowHeader('P', id) + stringify({reason, stack}) + '\n';
} else {
// No reason included in prod.
row = serializeRowHeader('P', id) + '\n';
}
const processedChunk = stringToChunk(row);
request.completedErrorChunks.push(processedChunk);
}
function emitErrorChunk(
request: Request,
id: number,
@@ -1321,19 +1381,27 @@ function retryTask(request: Request, task: Task): void {
// later, once we deprecate the old API in favor of `use`.
getSuspendedThenable()
: thrownValue;
// $FlowFixMe[method-unbinding]
if (typeof x === 'object' && x !== null && typeof x.then === 'function') {
// Something suspended again, let's pick it back up later.
const ping = task.ping;
x.then(ping, ping);
task.thenableState = getThenableStateAfterSuspending();
return;
} else {
request.abortableTasks.delete(task);
task.status = ERRORED;
const digest = logRecoverableError(request, x);
emitErrorChunk(request, task.id, digest, x);
if (typeof x === 'object' && x !== null) {
// $FlowFixMe[method-unbinding]
if (typeof x.then === 'function') {
// Something suspended again, let's pick it back up later.
const ping = task.ping;
x.then(ping, ping);
task.thenableState = getThenableStateAfterSuspending();
return;
} else if (enablePostpone && x.$$typeof === REACT_POSTPONE_TYPE) {
request.abortableTasks.delete(task);
task.status = ERRORED;
const postponeInstance: Postpone = (x: any);
logPostpone(request, postponeInstance.message);
emitPostponeChunk(request, task.id, postponeInstance);
return;
}
}
request.abortableTasks.delete(task);
task.status = ERRORED;
const digest = logRecoverableError(request, x);
emitErrorChunk(request, task.id, digest, x);
}
}
+1
View File
@@ -33,6 +33,7 @@ export {
unstable_Cache,
unstable_DebugTracingMode,
unstable_Offscreen,
unstable_postpone,
unstable_getCacheSignal,
unstable_getCacheForType,
unstable_SuspenseList,
+2
View File
@@ -36,6 +36,7 @@ import {lazy} from './ReactLazy';
import {forwardRef} from './ReactForwardRef';
import {memo} from './ReactMemo';
import {cache} from './ReactCache';
import {postpone} from './ReactPostpone';
import {
getCacheSignal,
getCacheForType,
@@ -100,6 +101,7 @@ export {
lazy,
memo,
cache,
postpone as unstable_postpone,
useCallback,
useContext,
useEffect,
+23
View File
@@ -0,0 +1,23 @@
/**
* Copyright (c) Meta Platforms, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
import {REACT_POSTPONE_TYPE} from 'shared/ReactSymbols';
declare class Postpone extends Error {
$$typeof: symbol;
}
export type {Postpone};
export function postpone(reason: string): void {
// eslint-disable-next-line react-internal/prod-error-codes
const postponeInstance: Postpone = (new Error(reason): any);
postponeInstance.$$typeof = REACT_POSTPONE_TYPE;
throw postponeInstance;
}
@@ -32,6 +32,7 @@ export {
unstable_SuspenseList,
unstable_getCacheSignal,
unstable_getCacheForType,
unstable_postpone,
useId,
useCallback,
useContext,
+2
View File
@@ -86,6 +86,8 @@ export const enableFormActions = __EXPERIMENTAL__;
export const enableBinaryFlight = __EXPERIMENTAL__;
export const enablePostpone = __EXPERIMENTAL__;
export const enableTransitionTracing = false;
// No known bugs, but needs performance testing
+2
View File
@@ -49,6 +49,8 @@ export const REACT_MEMO_CACHE_SENTINEL: symbol = Symbol.for(
'react.memo_cache_sentinel',
);
export const REACT_POSTPONE_TYPE: symbol = Symbol.for('react.postpone');
const MAYBE_ITERATOR_SYMBOL = Symbol.iterator;
const FAUX_ITERATOR_SYMBOL = '@@iterator';
@@ -37,6 +37,7 @@ export const enableCacheElement = true;
export const enableFetchInstrumentation = false;
export const enableFormActions = true; // Doesn't affect Native
export const enableBinaryFlight = true;
export const enablePostpone = false;
export const enableSchedulerDebugging = false;
export const debugRenderPhaseSideEffectsForStrictMode = true;
export const disableJavaScriptURLs = false;
@@ -25,6 +25,7 @@ export const enableCacheElement = false;
export const enableFetchInstrumentation = false;
export const enableFormActions = true; // Doesn't affect Native
export const enableBinaryFlight = true;
export const enablePostpone = false;
export const disableJavaScriptURLs = false;
export const disableCommentsAsDOMContainers = true;
export const disableInputAttributeSyncing = false;
@@ -25,6 +25,7 @@ export const enableCacheElement = __EXPERIMENTAL__;
export const enableFetchInstrumentation = true;
export const enableFormActions = true; // Doesn't affect Test Renderer
export const enableBinaryFlight = true;
export const enablePostpone = false;
export const disableJavaScriptURLs = false;
export const disableCommentsAsDOMContainers = true;
export const disableInputAttributeSyncing = false;
@@ -25,6 +25,7 @@ export const enableCacheElement = true;
export const enableFetchInstrumentation = false;
export const enableFormActions = true; // Doesn't affect Test Renderer
export const enableBinaryFlight = true;
export const enablePostpone = false;
export const disableJavaScriptURLs = false;
export const disableCommentsAsDOMContainers = true;
export const disableInputAttributeSyncing = false;
@@ -25,6 +25,7 @@ export const enableCacheElement = true;
export const enableFetchInstrumentation = false;
export const enableFormActions = true; // Doesn't affect Test Renderer
export const enableBinaryFlight = true;
export const enablePostpone = false;
export const enableSchedulerDebugging = false;
export const disableJavaScriptURLs = false;
export const disableCommentsAsDOMContainers = true;
@@ -77,6 +77,8 @@ export const enableFormActions = false;
export const enableBinaryFlight = true;
export const enablePostpone = false;
export const disableJavaScriptURLs = true;
// TODO: www currently relies on this feature. It's disabled in open source.
+3 -2
View File
@@ -468,5 +468,6 @@
"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.",
"481": "Tried to encode a Server Action from a different instance than the encoder is from. This is a bug in React.",
"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.",
"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."
}
"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.",
"484": "A Server Component was postponed. The reason is omitted in production builds to avoid leaking sensitive details."
}