mirror of
https://github.com/hanzoai/react.git
synced 2026-09-24 17:02:27 +00:00
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:
committed by
GitHub
parent
87c6735094
commit
3bdc4aba5b
+77
-3
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
+44
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ export {
|
||||
unstable_Cache,
|
||||
unstable_DebugTracingMode,
|
||||
unstable_Offscreen,
|
||||
unstable_postpone,
|
||||
unstable_getCacheSignal,
|
||||
unstable_getCacheForType,
|
||||
unstable_SuspenseList,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
Reference in New Issue
Block a user