diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
index e0a1b64c810c4..076c442f52831 100644
--- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
+++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
@@ -6365,6 +6365,6 @@ describe('ReactDOMFizzServer', () => {
resumed.pipe(writable);
});
- // TODO: expect(getVisibleChildren(container)).toEqual(
Hello
);
+ expect(getVisibleChildren(container)).toEqual(Hello
);
});
});
diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js
index 1a221dbb88c20..2c8944ebf9215 100644
--- a/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js
+++ b/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js
@@ -105,7 +105,7 @@ describe('ReactDOMFizzStaticBrowser', () => {
}
const temp = document.createElement('div');
temp.innerHTML = result;
- insertNodesAndExecuteScripts(temp, container, null);
+ await insertNodesAndExecuteScripts(temp, container, null);
}
// @gate experimental
@@ -490,7 +490,51 @@ describe('ReactDOMFizzStaticBrowser', () => {
await readIntoContainer(resumed);
- // TODO: expect(getVisibleChildren(container)).toEqual(Hello
);
+ expect(getVisibleChildren(container)).toEqual(
+ {['Hello', 'World']}
,
+ );
+ });
+
+ // @gate enablePostpone
+ it('supports postponing in prerender and resuming with a prefix', async () => {
+ let prerendering = true;
+ function Postpone() {
+ if (prerendering) {
+ React.unstable_postpone();
+ }
+ return 'World';
+ }
+
+ function App() {
+ return (
+
+ );
+ }
+
+ const prerendered = await ReactDOMFizzStatic.prerender();
+ expect(prerendered.postponed).not.toBe(null);
+
+ prerendering = false;
+
+ const resumed = await ReactDOMFizzServer.resume(
+ ,
+ prerendered.postponed,
+ );
+
+ await readIntoContainer(prerendered.prelude);
+
+ expect(getVisibleChildren(container)).toEqual(Loading...
);
+
+ await readIntoContainer(resumed);
+
+ expect(getVisibleChildren(container)).toEqual(
+ {['Hello', 'World']}
,
+ );
});
// @gate enablePostpone
@@ -500,7 +544,51 @@ describe('ReactDOMFizzStaticBrowser', () => {
React.unstable_postpone();
});
+ function App() {
+ return (
+
+
+ Hi
+ {prerendering ? Hole : 'Hello'}
+
+
+ );
+ }
+
+ const prerendered = await ReactDOMFizzStatic.prerender();
+ expect(prerendered.postponed).not.toBe(null);
+
+ prerendering = false;
+
+ const resumed = await ReactDOMFizzServer.resume(
+ ,
+ prerendered.postponed,
+ );
+
+ await readIntoContainer(prerendered.prelude);
+
+ expect(getVisibleChildren(container)).toEqual(Loading...
);
+
+ await readIntoContainer(resumed);
+
+ expect(getVisibleChildren(container)).toEqual(
+
+ {'Hi'}
+ {'Hello'}
+
,
+ );
+ });
+
+ // @gate enablePostpone
+ it('supports postponing in a nested array', async () => {
+ let prerendering = true;
+ const Hole = React.lazy(async () => {
+ React.unstable_postpone();
+ });
function Postpone() {
+ if (prerendering) {
+ React.unstable_postpone();
+ }
return 'Hello';
}
@@ -509,7 +597,48 @@ describe('ReactDOMFizzStaticBrowser', () => {
Hi
- {prerendering ? Hole : }
+ {[, prerendering ? Hole : 'World']}
+
+
+ );
+ }
+
+ const prerendered = await ReactDOMFizzStatic.prerender();
+ expect(prerendered.postponed).not.toBe(null);
+
+ prerendering = false;
+
+ const resumed = await ReactDOMFizzServer.resume(
+ ,
+ prerendered.postponed,
+ );
+
+ await readIntoContainer(prerendered.prelude);
+
+ expect(getVisibleChildren(container)).toEqual(Loading...
);
+
+ await readIntoContainer(resumed);
+
+ expect(getVisibleChildren(container)).toEqual(
+ {['Hi', 'Hello', 'World']}
,
+ );
+ });
+
+ // @gate enablePostpone
+ it('supports postponing in lazy as a direct child', async () => {
+ let prerendering = true;
+ const Hole = React.lazy(async () => {
+ React.unstable_postpone();
+ });
+ function Postpone() {
+ return prerendering ? Hole : 'Hello';
+ }
+
+ function App() {
+ return (
+
);
@@ -531,7 +660,7 @@ describe('ReactDOMFizzStaticBrowser', () => {
await readIntoContainer(resumed);
- // TODO: expect(getVisibleChildren(container)).toEqual(Hello
);
+ expect(getVisibleChildren(container)).toEqual(Hello
);
});
// @gate enablePostpone
diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js
index 64ecfd1d84e93..8c78241096889 100644
--- a/packages/react-server/src/ReactFizzServer.js
+++ b/packages/react-server/src/ReactFizzServer.js
@@ -168,7 +168,8 @@ export type KeyNode = [
const REPLAY_NODE = 0;
const REPLAY_SUSPENSE_BOUNDARY = 1;
const RESUME_ELEMENT = 2;
-const RESUME_SLOT = 3;
+const RESUME_SUSPENSE_BOUNDARY = 3;
+const RESUME_SLOT = 4;
type ReplaySuspenseBoundary = [
1, // REPLAY_SUSPENSE_BOUNDARY
@@ -188,6 +189,14 @@ type ReplayNode =
]
| ReplaySuspenseBoundary;
+type ResumeSuspenseBoundary = [
+ 3, // RESUME_SUSPENSE_BOUNDARY
+ string | null /* name */,
+ string | number /* key */,
+ SuspenseBoundaryID /* id */,
+ number /* rootSegmentID */,
+];
+
type ResumeElement = [
2, // RESUME_ELEMENT
string | null /* name */,
@@ -196,12 +205,16 @@ type ResumeElement = [
];
type ResumeSlot = [
- 3, // RESUME_SLOT
+ 4, // RESUME_SLOT
number /* index */,
number /* segment id */,
];
-type ResumableNode = ReplayNode | ResumeElement | ResumeSlot;
+type ResumableNode =
+ | ReplayNode
+ | ResumeElement
+ | ResumeSuspenseBoundary
+ | ResumeSlot;
type PostponedHoles = {
workingMap: Map,
@@ -230,7 +243,8 @@ type SuspenseBoundary = {
keyPath: Root | KeyNode,
};
-export type Task = {
+type RenderTask = {
+ replay: null,
node: ReactNodeList,
childIndex: number,
ping: () => void,
@@ -246,6 +260,32 @@ export type Task = {
thenableState: null | ThenableState,
};
+type ReplaySet = {
+ nodes: Array, // the possible paths to follow down the replaying
+ pendingTasks: number, // tracks the number of tasks currently tracking this set of nodes
+ // if pending tasks reach zero but there are still nodes left, it means we couldn't find
+ // them all in the tree, so we need to abort and client render the boundary.
+};
+
+type ReplayTask = {
+ replay: ReplaySet,
+ node: ReactNodeList,
+ childIndex: number,
+ ping: () => void,
+ blockedBoundary: Root | SuspenseBoundary,
+ blockedSegment: null, // we don't write to anything when we replay
+ abortSet: Set, // the abortable set that this task belongs to
+ keyPath: Root | KeyNode, // the path of all parent keys currently rendering
+ formatContext: FormatContext, // the format's specific context (e.g. HTML/SVG/MathML)
+ legacyContext: LegacyContext, // the current legacy context that this task is executing in
+ context: ContextSnapshot, // the current new context that this task is executing in
+ treeContext: TreeContext, // the current tree context that this task is executing in
+ componentStack: null | ComponentStackNode, // DEV-only component stack
+ thenableState: null | ThenableState,
+};
+
+export type Task = RenderTask | ReplayTask;
+
const PENDING = 0;
const COMPLETED = 1;
const FLUSHED = 2;
@@ -400,7 +440,7 @@ export function createRequest(
);
// There is no parent so conceptually, we're unblocked to flush this segment.
rootSegment.parentFlushed = true;
- const rootTask = createTask(
+ const rootTask = createRenderTask(
request,
null,
children,
@@ -490,25 +530,13 @@ export function resumeRequest(
onFatalError: onFatalError === undefined ? noop : onFatalError,
formState: null,
};
- // This segment represents the root fallback.
- const rootSegment = createPendingSegment(
- request,
- 0,
- null,
- postponedState.rootFormatContext,
- // Root segments are never embedded in Text on either edge
- false,
- false,
- );
- // There is no parent so conceptually, we're unblocked to flush this segment.
- rootSegment.parentFlushed = true;
- const rootTask = createTask(
+ const rootTask = createReplayTask(
request,
null,
+ {nodes: postponedState.resumablePath, pendingTasks: 0},
children,
-1,
null,
- rootSegment,
abortSet,
null,
postponedState.rootFormatContext,
@@ -560,7 +588,7 @@ function createSuspenseBoundary(
};
}
-function createTask(
+function createRenderTask(
request: Request,
thenableState: ThenableState | null,
node: ReactNodeList,
@@ -573,14 +601,15 @@ function createTask(
legacyContext: LegacyContext,
context: ContextSnapshot,
treeContext: TreeContext,
-): Task {
+): RenderTask {
request.allPendingTasks++;
if (blockedBoundary === null) {
request.pendingRootTasks++;
} else {
blockedBoundary.pendingTasks++;
}
- const task: Task = ({
+ const task: RenderTask = ({
+ replay: null,
node,
childIndex,
ping: () => pingTask(request, task),
@@ -601,6 +630,49 @@ function createTask(
return task;
}
+function createReplayTask(
+ request: Request,
+ thenableState: ThenableState | null,
+ replay: ReplaySet,
+ node: ReactNodeList,
+ childIndex: number,
+ blockedBoundary: Root | SuspenseBoundary,
+ abortSet: Set,
+ keyPath: Root | KeyNode,
+ formatContext: FormatContext,
+ legacyContext: LegacyContext,
+ context: ContextSnapshot,
+ treeContext: TreeContext,
+): ReplayTask {
+ request.allPendingTasks++;
+ if (blockedBoundary === null) {
+ request.pendingRootTasks++;
+ } else {
+ blockedBoundary.pendingTasks++;
+ }
+ replay.pendingTasks++;
+ const task: ReplayTask = ({
+ replay,
+ node,
+ childIndex,
+ ping: () => pingTask(request, task),
+ blockedBoundary,
+ blockedSegment: null,
+ abortSet,
+ keyPath,
+ formatContext,
+ legacyContext,
+ context,
+ treeContext,
+ thenableState,
+ }: any);
+ if (__DEV__) {
+ task.componentStack = null;
+ }
+ abortSet.add(task);
+ return task;
+}
+
function createPendingSegment(
request: Request,
index: number,
@@ -739,10 +811,19 @@ function fatalError(request: Request, error: mixed): void {
function renderSuspenseBoundary(
request: Request,
- task: Task,
+ someTask: Task,
keyPath: Root | KeyNode,
props: Object,
): void {
+ if (someTask.replay !== null) {
+ throw new Error(
+ 'Did not expect to see a Suspense boundary in this slot. ' +
+ "The tree doesn't match so React will fallback to client rendering.",
+ );
+ }
+ // $FlowFixMe: Refined.
+ const task: RenderTask = someTask;
+
pushBuiltInComponentStackInDEV(task, 'Suspense');
const prevKeyPath = task.keyPath;
@@ -866,7 +947,7 @@ function renderSuspenseBoundary(
// We create suspended task for the fallback because we don't want to actually work
// on it yet in case we finish the main content, so we queue for later.
- const suspendedFallbackTask = createTask(
+ const suspendedFallbackTask = createRenderTask(
request,
null,
fallback,
@@ -891,6 +972,203 @@ function renderSuspenseBoundary(
popComponentStackInDEV(task);
}
+function replaySuspenseBoundary(
+ request: Request,
+ task: ReplayTask,
+ props: Object,
+ replayNode: ReplaySuspenseBoundary,
+): void {
+ pushBuiltInComponentStackInDEV(task, 'Suspense');
+
+ const previousReplaySet: ReplaySet = task.replay;
+
+ const parentBoundary = task.blockedBoundary;
+
+ const content: ReactNodeList = props.children;
+
+ const fallbackAbortSet: Set = new Set();
+ const resumedBoundary = createSuspenseBoundary(
+ request,
+ fallbackAbortSet,
+ task.keyPath,
+ );
+ resumedBoundary.parentFlushed = true;
+ // We restore the same id of this boundary as was used during prerender.
+ resumedBoundary.id = replayNode[4];
+ resumedBoundary.rootSegmentID = replayNode[5];
+
+ // We can reuse the current context and task to render the content immediately without
+ // context switching. We just need to temporarily switch which boundary and replay node
+ // we're writing to. If something suspends, it'll spawn new suspended task with that context.
+ task.blockedBoundary = resumedBoundary;
+ task.replay = {nodes: replayNode[3], pendingTasks: 1};
+ if (enableFloat) {
+ // Does this even matter for replaying?
+ setCurrentlyRenderingBoundaryResourcesTarget(
+ request.renderState,
+ resumedBoundary.resources,
+ );
+ }
+ try {
+ // We use the safe form because we don't handle suspending here. Only error handling.
+ renderNode(request, task, content, -1);
+ if (
+ resumedBoundary.pendingTasks === 0 &&
+ resumedBoundary.status === PENDING
+ ) {
+ resumedBoundary.status = COMPLETED;
+ request.completedBoundaries.push(resumedBoundary);
+ }
+ if (task.replay.pendingTasks === 1 && task.replay.nodes.length > 0) {
+ throw new Error(
+ "Couldn't find all resumable slots by key/index during replaying. " +
+ "The tree doesn't match so React will fallback to client rendering.",
+ );
+ }
+ task.replay.pendingTasks--;
+ } catch (error) {
+ resumedBoundary.status = CLIENT_RENDERED;
+ 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);
+ }
+ resumedBoundary.errorDigest = errorDigest;
+ if (__DEV__) {
+ captureBoundaryErrorDetailsDev(resumedBoundary, error);
+ }
+
+ task.replay.pendingTasks--;
+
+ // We don't need to decrement any task numbers because we didn't spawn any new task.
+ // We don't need to schedule any task because we know the parent has written yet.
+ // We do need to fallthrough to create the fallback though.
+ } finally {
+ if (enableFloat) {
+ setCurrentlyRenderingBoundaryResourcesTarget(
+ request.renderState,
+ parentBoundary ? parentBoundary.resources : null,
+ );
+ }
+ task.blockedBoundary = parentBoundary;
+ task.replay = previousReplaySet;
+ }
+ // TODO: Should this be in the finally?
+ popComponentStackInDEV(task);
+}
+
+function resumeSuspenseBoundary(
+ request: Request,
+ task: ReplayTask,
+ props: Object,
+ replayNode: ResumeSuspenseBoundary,
+): void {
+ pushBuiltInComponentStackInDEV(task, 'Suspense');
+
+ const previousReplaySet: ReplaySet = task.replay;
+
+ const parentBoundary = task.blockedBoundary;
+
+ const content: ReactNodeList = props.children;
+
+ const fallbackAbortSet: Set = new Set();
+ const resumedBoundary = createSuspenseBoundary(
+ request,
+ fallbackAbortSet,
+ task.keyPath,
+ );
+ resumedBoundary.parentFlushed = true;
+ // We restore the same id of this boundary as was used during prerender.
+ resumedBoundary.id = replayNode[3];
+ resumedBoundary.rootSegmentID = replayNode[4];
+
+ const resumedSegment = createPendingSegment(
+ request,
+ 0,
+ null,
+ task.formatContext,
+ false,
+ false,
+ );
+ resumedSegment.parentFlushed = true;
+ resumedSegment.id = replayNode[4];
+
+ // We can reuse the current context and task to render the content immediately without
+ // context switching. We just need to temporarily switch which boundary and replay node
+ // we're writing to. If something suspends, it'll spawn new suspended task with that context.
+ task.blockedBoundary = resumedBoundary;
+ if (enableFloat) {
+ // Does this even matter for replaying?
+ setCurrentlyRenderingBoundaryResourcesTarget(
+ request.renderState,
+ resumedBoundary.resources,
+ );
+ }
+ try {
+ // Convert the current ReplayTask to a RenderTask.
+ const renderTask: RenderTask = (task: any);
+ renderTask.replay = null;
+ renderTask.blockedSegment = resumedSegment;
+ // We use the safe form because we don't handle suspending here. Only error handling.
+ renderNode(request, task, content, -1);
+ resumedSegment.status = COMPLETED;
+ queueCompletedSegment(resumedBoundary, resumedSegment);
+ if (
+ resumedBoundary.pendingTasks === 0 &&
+ resumedBoundary.status === PENDING
+ ) {
+ resumedBoundary.status = COMPLETED;
+ request.completedBoundaries.push(resumedBoundary);
+ }
+ } catch (error) {
+ resumedBoundary.status = CLIENT_RENDERED;
+ 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);
+ }
+ resumedBoundary.errorDigest = errorDigest;
+ if (__DEV__) {
+ captureBoundaryErrorDetailsDev(resumedBoundary, error);
+ }
+
+ // We don't need to decrement any task numbers because we didn't spawn any new task.
+ // We don't need to schedule any task because we know the parent has written yet.
+ // We do need to fallthrough to create the fallback though.
+ } finally {
+ if (enableFloat) {
+ setCurrentlyRenderingBoundaryResourcesTarget(
+ request.renderState,
+ parentBoundary ? parentBoundary.resources : null,
+ );
+ }
+ task.blockedBoundary = parentBoundary;
+ // Restore to a ReplayTask
+ task.blockedSegment = null;
+ task.replay = previousReplaySet;
+ }
+ // TODO: Should this be in the finally?
+ popComponentStackInDEV(task);
+}
+
function renderBackupSuspenseBoundary(
request: Request,
task: Task,
@@ -901,13 +1179,18 @@ function renderBackupSuspenseBoundary(
const content = props.children;
const segment = task.blockedSegment;
-
- pushStartCompletedSuspenseBoundary(segment.chunks);
const prevKeyPath = task.keyPath;
task.keyPath = keyPath;
- renderNode(request, task, content, -1);
+ if (segment === null) {
+ // Replay
+ renderNode(request, task, content, -1);
+ } else {
+ // Render
+ pushStartCompletedSuspenseBoundary(segment.chunks);
+ renderNode(request, task, content, -1);
+ pushEndCompletedSuspenseBoundary(segment.chunks);
+ }
task.keyPath = prevKeyPath;
- pushEndCompletedSuspenseBoundary(segment.chunks);
popComponentStackInDEV(task);
}
@@ -921,38 +1204,56 @@ function renderHostElement(
): void {
pushBuiltInComponentStackInDEV(task, type);
const segment = task.blockedSegment;
+ if (segment === null) {
+ // Replay
+ const children = props.children; // TODO: Make this a Config for replaying.
+ const prevContext = task.formatContext;
+ const prevKeyPath = task.keyPath;
+ task.formatContext = getChildFormatContext(prevContext, type, props);
+ task.keyPath = keyPath;
- const children = pushStartInstance(
- segment.chunks,
- type,
- props,
- request.resumableState,
- request.renderState,
- task.formatContext,
- segment.lastPushedText,
- );
- segment.lastPushedText = false;
- const prevContext = task.formatContext;
- const prevKeyPath = task.keyPath;
- task.formatContext = getChildFormatContext(prevContext, type, props);
- task.keyPath = keyPath;
+ // We use the non-destructive form because if something suspends, we still
+ // need to pop back up and finish this subtree of HTML.
+ renderNode(request, task, children, -1);
+
+ // We expect that errors will fatal the whole task and that we don't need
+ // the correct context. Therefore this is not in a finally.
+ task.formatContext = prevContext;
+ task.keyPath = prevKeyPath;
+ } else {
+ // Render
+ const children = pushStartInstance(
+ segment.chunks,
+ type,
+ props,
+ request.resumableState,
+ request.renderState,
+ task.formatContext,
+ segment.lastPushedText,
+ );
+ segment.lastPushedText = false;
+ const prevContext = task.formatContext;
+ const prevKeyPath = task.keyPath;
+ task.formatContext = getChildFormatContext(prevContext, type, props);
+ task.keyPath = keyPath;
- // We use the non-destructive form because if something suspends, we still
- // need to pop back up and finish this subtree of HTML.
- renderNode(request, task, children, -1);
+ // We use the non-destructive form because if something suspends, we still
+ // need to pop back up and finish this subtree of HTML.
+ renderNode(request, task, children, -1);
- // We expect that errors will fatal the whole task and that we don't need
- // the correct context. Therefore this is not in a finally.
- task.formatContext = prevContext;
- task.keyPath = prevKeyPath;
- pushEndInstance(
- segment.chunks,
- type,
- props,
- request.resumableState,
- prevContext,
- );
- segment.lastPushedText = false;
+ // We expect that errors will fatal the whole task and that we don't need
+ // the correct context. Therefore this is not in a finally.
+ task.formatContext = prevContext;
+ task.keyPath = prevKeyPath;
+ pushEndInstance(
+ segment.chunks,
+ type,
+ props,
+ request.resumableState,
+ prevContext,
+ );
+ segment.lastPushedText = false;
+ }
popComponentStackInDEV(task);
}
@@ -1646,9 +1947,234 @@ function renderElement(
);
}
-// $FlowFixMe[missing-local-annot]
-function validateIterable(iterable, iteratorFn: Function): void {
- if (__DEV__) {
+function resumeNode(
+ request: Request,
+ task: ReplayTask,
+ segmentId: number,
+ node: ReactNodeList,
+ childIndex: number,
+): void {
+ const prevReplay = task.replay;
+ const blockedBoundary = task.blockedBoundary;
+ const resumedSegment = createPendingSegment(
+ request,
+ 0,
+ null,
+ task.formatContext,
+ false,
+ false,
+ );
+ resumedSegment.id = segmentId;
+ resumedSegment.parentFlushed = true;
+ try {
+ // Convert the current ReplayTask to a RenderTask.
+ const renderTask: RenderTask = (task: any);
+ renderTask.replay = null;
+ renderTask.blockedSegment = resumedSegment;
+ renderNode(request, task, node, childIndex);
+ resumedSegment.status = COMPLETED;
+ if (blockedBoundary === null) {
+ request.completedRootSegment = resumedSegment;
+ } else {
+ queueCompletedSegment(blockedBoundary, resumedSegment);
+ if (blockedBoundary.parentFlushed) {
+ request.partialBoundaries.push(blockedBoundary);
+ }
+ }
+ } finally {
+ // Restore to a ReplayTask.
+ task.replay = prevReplay;
+ task.blockedSegment = null;
+ }
+}
+
+function resumeElement(
+ request: Request,
+ task: ReplayTask,
+ keyPath: Root | KeyNode,
+ segmentId: number,
+ prevThenableState: ThenableState | null,
+ type: any,
+ props: Object,
+ ref: any,
+): void {
+ const prevReplay = task.replay;
+ const blockedBoundary = task.blockedBoundary;
+ const resumedSegment = createPendingSegment(
+ request,
+ 0,
+ null,
+ task.formatContext,
+ false,
+ false,
+ );
+ resumedSegment.id = segmentId;
+ resumedSegment.parentFlushed = true;
+ try {
+ // Convert the current ReplayTask to a RenderTask.
+ const renderTask: RenderTask = (task: any);
+ renderTask.replay = null;
+ renderTask.blockedSegment = resumedSegment;
+ renderElement(request, task, keyPath, prevThenableState, type, props, ref);
+ resumedSegment.status = COMPLETED;
+ if (blockedBoundary === null) {
+ request.completedRootSegment = resumedSegment;
+ } else {
+ queueCompletedSegment(blockedBoundary, resumedSegment);
+ if (blockedBoundary.parentFlushed) {
+ request.partialBoundaries.push(blockedBoundary);
+ }
+ }
+ } finally {
+ // Restore to a ReplayTask.
+ task.replay = prevReplay;
+ task.blockedSegment = null;
+ }
+}
+
+function replayElement(
+ request: Request,
+ task: ReplayTask,
+ keyPath: Root | KeyNode,
+ prevThenableState: ThenableState | null,
+ name: null | string,
+ keyOrIndex: number | string,
+ childIndex: number,
+ type: any,
+ props: Object,
+ ref: any,
+ replay: ReplaySet,
+): void {
+ // We're replaying. Find the path to follow.
+ const replayNodes = replay.nodes;
+ for (let i = 0; i < replayNodes.length; i++) {
+ // Flow doesn't support refinement on tuples so we do it manually here.
+ const candidate: any = replayNodes[i];
+ switch (candidate[0]) {
+ case REPLAY_NODE: {
+ const node: ReplayNode = candidate;
+ if (keyOrIndex === node[2]) {
+ // Let's double check that the component name matches as a precaution.
+ if (name !== null && name !== node[1]) {
+ throw new Error(
+ 'Expected to see a component of type "' +
+ name +
+ '" in this slot. ' +
+ "The tree doesn't match so React will fallback to client rendering.",
+ );
+ }
+ // Matched a replayable path.
+ task.replay = {nodes: node[3], pendingTasks: 1};
+ try {
+ renderElement(
+ request,
+ task,
+ keyPath,
+ prevThenableState,
+ type,
+ props,
+ ref,
+ );
+ // We finished rendering this node, so now we can consume this
+ // slot. This must happen after in case we rerender this task.
+ replayNodes.splice(i, 1);
+ } finally {
+ task.replay.pendingTasks--;
+ if (
+ task.replay.pendingTasks === 0 &&
+ task.replay.nodes.length > 0
+ ) {
+ throw new Error(
+ "Couldn't find all resumable slots by key/index during replaying. " +
+ "The tree doesn't match so React will fallback to client rendering.",
+ );
+ }
+ task.replay = replay;
+ }
+ }
+ continue;
+ }
+ case REPLAY_SUSPENSE_BOUNDARY: {
+ const node: ReplaySuspenseBoundary = candidate;
+ if (keyOrIndex === node[2]) {
+ // Let's double check that the component type matches.
+ if (type !== REACT_SUSPENSE_TYPE) {
+ throw new Error(
+ 'Expected to see a Suspense boundary in this slot. ' +
+ "The tree doesn't match so React will fallback to client rendering.",
+ );
+ }
+ // Matched a replayable path.
+ replaySuspenseBoundary(request, task, props, node);
+ // We finished rendering this node, so now we can consume this
+ // slot. This must happen after in case we rerender this task.
+ replayNodes.splice(i, 1);
+ }
+ continue;
+ }
+ case RESUME_ELEMENT: {
+ const node: ResumeElement = candidate;
+ if (keyOrIndex === node[2]) {
+ // Let's double check that the component name matches as a precaution.
+ if (name !== node[1]) {
+ throw new Error(
+ 'Expected to see a component of type "' +
+ (name || 'unknown') +
+ '" in this slot. ' +
+ "The tree doesn't match so React will fallback to client rendering.",
+ );
+ }
+ // Matched a resumable element.
+
+ const segmentId = node[3];
+
+ resumeElement(
+ request,
+ task,
+ keyPath,
+ segmentId,
+ prevThenableState,
+ type,
+ props,
+ ref,
+ );
+
+ // We finished rendering this node, so now we can consume this
+ // slot. This must happen after in case we rerender this task.
+ replayNodes.splice(i, 1);
+ }
+ continue;
+ }
+ case RESUME_SUSPENSE_BOUNDARY: {
+ const node: ResumeSuspenseBoundary = candidate;
+ if (keyOrIndex === node[2]) {
+ // Let's double check that the component name matches as a precaution.
+ if (type !== REACT_SUSPENSE_TYPE) {
+ throw new Error(
+ 'Expected to see a Suspense boundary in this slot. ' +
+ "The tree doesn't match so React will fallback to client rendering.",
+ );
+ }
+ // Matched a resumable suspense boundary.
+ resumeSuspenseBoundary(request, task, props, node);
+
+ // We finished rendering this node, so now we can consume this
+ // slot. This must happen after in case we rerender this task.
+ replayNodes.splice(i, 1);
+ }
+ continue;
+ }
+ // For RESUME_SLOT we ignore them here and assume we've handled them
+ // separately already.
+ }
+ }
+ // We didn't find any matching nodes. We assume that this element was already
+ // rendered in the prelude and skip it.
+}
+
+// $FlowFixMe[missing-local-annot]
+function validateIterable(iterable, iteratorFn: Function): void {
+ if (__DEV__) {
// We don't support rendering Generators because it's a mutation.
// See https://github.com/facebook/react/issues/12995
if (
@@ -1749,20 +2275,37 @@ function renderNodeDestructiveImpl(
const props = element.props;
const ref = element.ref;
const name = getComponentNameFromType(type);
- const keyPath = [
- task.keyPath,
- name,
- key == null ? (childIndex === -1 ? 0 : childIndex) : key,
- ];
- renderElement(
- request,
- task,
- keyPath,
- prevThenableState,
- type,
- props,
- ref,
- );
+ const keyOrIndex =
+ key == null ? (childIndex === -1 ? 0 : childIndex) : key;
+ const keyPath = [task.keyPath, name, keyOrIndex];
+ if (task.replay !== null) {
+ replayElement(
+ request,
+ task,
+ keyPath,
+ prevThenableState,
+ name,
+ keyOrIndex,
+ childIndex,
+ type,
+ props,
+ ref,
+ task.replay,
+ );
+ // No matches found for this node. We assume it's already emitted in the
+ // prelude and skip it during the replay.
+ } else {
+ // We're doing a plain render.
+ renderElement(
+ request,
+ task,
+ keyPath,
+ prevThenableState,
+ type,
+ props,
+ ref,
+ );
+ }
return;
}
case REACT_PORTAL_TYPE:
@@ -1883,23 +2426,33 @@ function renderNodeDestructiveImpl(
if (typeof node === 'string') {
const segment = task.blockedSegment;
- segment.lastPushedText = pushTextInstance(
- task.blockedSegment.chunks,
- node,
- request.renderState,
- segment.lastPushedText,
- );
+ if (segment === null) {
+ // We assume a text node doesn't have a representation in the replay set,
+ // since it can't postpone. If it does, it'll be left unmatched and error.
+ } else {
+ segment.lastPushedText = pushTextInstance(
+ segment.chunks,
+ node,
+ request.renderState,
+ segment.lastPushedText,
+ );
+ }
return;
}
if (typeof node === 'number') {
const segment = task.blockedSegment;
- segment.lastPushedText = pushTextInstance(
- task.blockedSegment.chunks,
- '' + node,
- request.renderState,
- segment.lastPushedText,
- );
+ if (segment === null) {
+ // We assume a text node doesn't have a representation in the replay set,
+ // since it can't postpone. If it does, it'll be left unmatched and error.
+ } else {
+ segment.lastPushedText = pushTextInstance(
+ segment.chunks,
+ '' + node,
+ request.renderState,
+ segment.lastPushedText,
+ );
+ }
return;
}
@@ -1919,13 +2472,85 @@ function renderChildrenArray(
task: Task,
children: Array,
childIndex: number,
-) {
+): void {
const prevKeyPath = task.keyPath;
if (childIndex !== -1) {
- task.keyPath = [task.keyPath, '', childIndex];
+ task.keyPath = [task.keyPath, 'Fragment', childIndex];
+ if (task.replay !== null) {
+ // If we're supposed follow this array, we'd expect to see a ReplayNode matching
+ // this fragment.
+ const replayTask: ReplayTask = task;
+ const replay = task.replay;
+ const replayNodes = replay.nodes;
+ for (let j = 0; j < replayNodes.length; j++) {
+ const replayNode = replayNodes[j];
+ if (replayNode[0] !== REPLAY_NODE) {
+ continue;
+ }
+ const node: ReplayNode = (replayNode: any);
+ if (node[2] !== childIndex) {
+ continue;
+ }
+ // Matched a replayable path.
+ replayTask.replay = {nodes: node[3], pendingTasks: 1};
+ try {
+ renderChildrenArray(request, task, children, -1);
+ } finally {
+ replayTask.replay.pendingTasks--;
+ if (
+ replayTask.replay.pendingTasks === 0 &&
+ replayTask.replay.nodes.length > 0
+ ) {
+ throw new Error(
+ "Couldn't find all resumable slots by key/index during replaying. " +
+ "The tree doesn't match so React will fallback to client rendering.",
+ );
+ }
+ replayTask.replay = replay;
+ }
+ // We finished rendering this node, so now we can consume this
+ // slot. This must happen after in case we rerender this task.
+ replayNodes.splice(j, 1);
+ break;
+ }
+ task.keyPath = prevKeyPath;
+ return;
+ }
}
const prevTreeContext = task.treeContext;
const totalChildren = children.length;
+
+ if (task.replay !== null) {
+ // Replay
+ // First we need to check if we have any resume slots at this level.
+ // TODO: This could be simpler if we just stored RESUME_SLOT in a separate set.
+ let hadOtherReplayNodes = false;
+ const replayNodes = task.replay.nodes;
+ for (let j = 0; j < replayNodes.length; ) {
+ const replayNode = replayNodes[j];
+ if (replayNode[0] !== RESUME_SLOT) {
+ hadOtherReplayNodes = true;
+ j++; // skip
+ continue;
+ }
+ const resumeSlot: ResumeSlot = (replayNode: any);
+ const i = resumeSlot[1]; // The index of the child to resume.
+ const segmentId = resumeSlot[2];
+ task.treeContext = pushTreeContext(prevTreeContext, totalChildren, i);
+ resumeNode(request, task, segmentId, children[i], i);
+ // We finished rendering this node, so now we can consume this
+ // slot. This must happen after in case we rerender this task.
+ replayNodes.splice(j, 1);
+ }
+ // If had non-resume slot nodes, we need to also try to match them below.
+ if (!hadOtherReplayNodes) {
+ // If we didn't, we can bail early.
+ task.treeContext = prevTreeContext;
+ task.keyPath = prevKeyPath;
+ return;
+ }
+ }
+
for (let i = 0; i < totalChildren; i++) {
const node = children[i];
task.treeContext = pushTreeContext(prevTreeContext, totalChildren, i);
@@ -1933,6 +2558,7 @@ function renderChildrenArray(
// up and render the sibling if something suspends.
renderNode(request, task, node, i);
}
+
// Because this context is always set right before rendering every child, we
// only need to reset it to the previous value at the very end.
task.treeContext = prevTreeContext;
@@ -1946,8 +2572,13 @@ function trackPostpone(
segment: Segment,
): void {
segment.status = POSTPONED;
- // We know that this will leave a hole so we might as well assign an ID now.
- segment.id = request.nextSegmentId++;
+
+ const keyPath = task.keyPath;
+ if (keyPath === null) {
+ throw new Error(
+ 'It should not be possible to postpone at the root. This is a bug in React.',
+ );
+ }
const boundary = task.blockedBoundary;
if (boundary !== null && boundary.status === PENDING) {
@@ -1966,29 +2597,52 @@ function trackPostpone(
'It should not be possible to postpone at the root. This is a bug in React.',
);
}
- const children: Array = [];
- const boundaryNode: ReplaySuspenseBoundary = [
- REPLAY_SUSPENSE_BOUNDARY,
- boundaryKeyPath[1],
- boundaryKeyPath[2],
- children,
- boundary.id,
- boundary.rootSegmentID,
- ];
- trackedPostpones.workingMap.set(boundaryKeyPath, boundaryNode);
- addToReplayParent(boundaryNode, boundaryKeyPath[0], trackedPostpones);
+
+ if (boundaryKeyPath === keyPath && task.childIndex === -1) {
+ // Since we postponed directly in the Suspense boundary we can't have written anything
+ // to its segment. Therefore this will end up becoming the root segment.
+ segment.id = boundary.rootSegmentID;
+ // We postponed directly inside the Suspense boundary so we mark this for resuming.
+ const boundaryNode: ResumeSuspenseBoundary = [
+ RESUME_SUSPENSE_BOUNDARY,
+ boundaryKeyPath[1],
+ boundaryKeyPath[2],
+ boundary.id,
+ boundary.rootSegmentID,
+ ];
+ addToReplayParent(boundaryNode, boundaryKeyPath[0], trackedPostpones);
+ return;
+ } else {
+ const children: Array = [];
+ const boundaryNode: ReplaySuspenseBoundary = [
+ REPLAY_SUSPENSE_BOUNDARY,
+ boundaryKeyPath[1],
+ boundaryKeyPath[2],
+ children,
+ boundary.id,
+ boundary.rootSegmentID,
+ ];
+ trackedPostpones.workingMap.set(boundaryKeyPath, boundaryNode);
+ addToReplayParent(boundaryNode, boundaryKeyPath[0], trackedPostpones);
+ // Fall through to add the child node.
+ }
}
- const keyPath = task.keyPath;
- if (keyPath === null) {
- throw new Error(
- 'It should not be possible to postpone at the root. This is a bug in React.',
- );
+ // We know that this will leave a hole so we might as well assign an ID now.
+ // We might have one already if we had a parent that gave us its ID.
+ if (segment.id === -1) {
+ if (segment.parentFlushed && boundary !== null) {
+ // If this segment's parent was already flushed, it means we really just
+ // skipped the parent and this segment is now the root.
+ segment.id = boundary.rootSegmentID;
+ } else {
+ segment.id = request.nextSegmentId++;
+ }
}
if (task.childIndex === -1) {
- // Resume at the position before the first array
- const resumableElement = [
+ // Resume starting from directly inside the previous parent element.
+ const resumableElement: ResumeElement = [
RESUME_ELEMENT,
keyPath[1],
keyPath[2],
@@ -2004,7 +2658,7 @@ function trackPostpone(
function injectPostponedHole(
request: Request,
- task: Task,
+ task: RenderTask,
reason: string,
): Segment {
logPostpone(request, reason);
@@ -2027,9 +2681,41 @@ function injectPostponedHole(
return newSegment;
}
-function spawnNewSuspendedTask(
+function spawnNewSuspendedReplayTask(
request: Request,
- task: Task,
+ task: ReplayTask,
+ thenableState: ThenableState | null,
+ x: Wakeable,
+): void {
+ const newTask = createReplayTask(
+ request,
+ thenableState,
+ task.replay,
+ task.node,
+ task.childIndex,
+ task.blockedBoundary,
+ task.abortSet,
+ task.keyPath,
+ task.formatContext,
+ task.legacyContext,
+ task.context,
+ task.treeContext,
+ );
+
+ if (__DEV__) {
+ if (task.componentStack !== null) {
+ // We pop one task off the stack because the node that suspended will be tried again,
+ // which will add it back onto the stack.
+ newTask.componentStack = task.componentStack.parent;
+ }
+ }
+ const ping = newTask.ping;
+ x.then(ping, ping);
+}
+
+function spawnNewSuspendedRenderTask(
+ request: Request,
+ task: RenderTask,
thenableState: ThenableState | null,
x: Wakeable,
): void {
@@ -2049,7 +2735,7 @@ function spawnNewSuspendedTask(
segment.children.push(newSegment);
// Reset lastPushedText for current Segment since the new Segment "consumed" it
segment.lastPushedText = false;
- const newTask = createTask(
+ const newTask = createRenderTask(
request,
thenableState,
task.node,
@@ -2083,12 +2769,6 @@ function renderNode(
node: ReactNodeList,
childIndex: number,
): void {
- // Store how much we've pushed at this point so we can reset it in case something
- // suspended partially through writing something.
- const segment = task.blockedSegment;
- const childrenLength = segment.children.length;
- const chunkLength = segment.chunks.length;
-
// Snapshot the current context in case something throws to interrupt the
// process.
const previousFormatContext = task.formatContext;
@@ -2100,102 +2780,164 @@ function renderNode(
if (__DEV__) {
previousComponentStack = task.componentStack;
}
- try {
- return renderNodeDestructive(request, task, null, node, childIndex);
- } catch (thrownValue) {
- resetHooksState();
+ let x;
+ // Store how much we've pushed at this point so we can reset it in case something
+ // suspended partially through writing something.
+ const segment = task.blockedSegment;
+ if (segment === null) {
+ // Replay
+ try {
+ return renderNodeDestructive(request, task, null, node, childIndex);
+ } catch (thrownValue) {
+ resetHooksState();
+
+ x =
+ thrownValue === SuspenseException
+ ? // This is a special type of exception used for Suspense. For historical
+ // reasons, the rest of the Suspense implementation expects the thrown
+ // value to be a thenable, because before `use` existed that was the
+ // (unstable) API for suspending. This implementation detail can change
+ // later, once we deprecate the old API in favor of `use`.
+ getSuspendedThenable()
+ : thrownValue;
+
+ if (typeof x === 'object' && x !== null) {
+ // $FlowFixMe[method-unbinding]
+ if (typeof x.then === 'function') {
+ const wakeable: Wakeable = (x: any);
+ const thenableState = getThenableStateAfterSuspending();
+ spawnNewSuspendedReplayTask(
+ request,
+ // $FlowFixMe: Refined.
+ task,
+ thenableState,
+ wakeable,
+ );
- // Reset the write pointers to where we started.
- segment.children.length = childrenLength;
- segment.chunks.length = chunkLength;
+ // Restore the context. We assume that this will be restored by the inner
+ // functions in case nothing throws so we don't use "finally" here.
+ task.formatContext = previousFormatContext;
+ task.legacyContext = previousLegacyContext;
+ task.context = previousContext;
+ task.keyPath = previousKeyPath;
+ task.treeContext = previousTreeContext;
+ // Restore all active ReactContexts to what they were before.
+ switchContext(previousContext);
+ if (__DEV__) {
+ task.componentStack = previousComponentStack;
+ }
+ return;
+ }
+ }
- const x =
- thrownValue === SuspenseException
- ? // This is a special type of exception used for Suspense. For historical
- // reasons, the rest of the Suspense implementation expects the thrown
- // value to be a thenable, because before `use` existed that was the
- // (unstable) API for suspending. This implementation detail can change
- // later, once we deprecate the old API in favor of `use`.
- getSuspendedThenable()
- : thrownValue;
+ // TODO: Abort any undiscovered Suspense boundaries in the ResumableNode.
+ }
+ } else {
+ // Render
+ const childrenLength = segment.children.length;
+ const chunkLength = segment.chunks.length;
+ try {
+ return renderNodeDestructive(request, task, null, node, childIndex);
+ } catch (thrownValue) {
+ resetHooksState();
+
+ // Reset the write pointers to where we started.
+ segment.children.length = childrenLength;
+ segment.chunks.length = chunkLength;
+
+ x =
+ thrownValue === SuspenseException
+ ? // This is a special type of exception used for Suspense. For historical
+ // reasons, the rest of the Suspense implementation expects the thrown
+ // value to be a thenable, because before `use` existed that was the
+ // (unstable) API for suspending. This implementation detail can change
+ // later, once we deprecate the old API in favor of `use`.
+ getSuspendedThenable()
+ : thrownValue;
+
+ if (typeof x === 'object' && x !== null) {
+ // $FlowFixMe[method-unbinding]
+ if (typeof x.then === 'function') {
+ const wakeable: Wakeable = (x: any);
+ const thenableState = getThenableStateAfterSuspending();
+ spawnNewSuspendedRenderTask(
+ request,
+ // $FlowFixMe: Refined.
+ task,
+ thenableState,
+ wakeable,
+ );
- if (typeof x === 'object' && x !== null) {
- // $FlowFixMe[method-unbinding]
- if (typeof x.then === 'function') {
- const wakeable: Wakeable = (x: any);
- const thenableState = getThenableStateAfterSuspending();
- spawnNewSuspendedTask(request, task, thenableState, wakeable);
-
- // Restore the context. We assume that this will be restored by the inner
- // functions in case nothing throws so we don't use "finally" here.
- task.formatContext = previousFormatContext;
- task.legacyContext = previousLegacyContext;
- task.context = previousContext;
- task.keyPath = previousKeyPath;
- task.treeContext = previousTreeContext;
- // Restore all active ReactContexts to what they were before.
- switchContext(previousContext);
- if (__DEV__) {
- task.componentStack = previousComponentStack;
+ // Restore the context. We assume that this will be restored by the inner
+ // functions in case nothing throws so we don't use "finally" here.
+ task.formatContext = previousFormatContext;
+ task.legacyContext = previousLegacyContext;
+ task.context = previousContext;
+ task.keyPath = previousKeyPath;
+ task.treeContext = previousTreeContext;
+ // Restore all active ReactContexts to what they were before.
+ switchContext(previousContext);
+ if (__DEV__) {
+ task.componentStack = previousComponentStack;
+ }
+ return;
}
- return;
- }
- if (
- enablePostpone &&
- request.trackedPostpones !== null &&
- x.$$typeof === REACT_POSTPONE_TYPE &&
- task.blockedBoundary !== null // TODO: Support holes in the shell
- ) {
- // If we're tracking postpones, we inject a hole here and continue rendering
- // sibling. Similar to suspending. If we're not tracking, we treat it more like
- // an error. Notably this doesn't spawn a new task since nothing will fill it
- // in during this prerender.
- const postponeInstance: Postpone = (x: any);
- const trackedPostpones = request.trackedPostpones;
- const postponedSegment = injectPostponedHole(
- request,
- task,
- postponeInstance.message,
- );
- trackPostpone(request, trackedPostpones, task, postponedSegment);
-
- // Restore the context. We assume that this will be restored by the inner
- // functions in case nothing throws so we don't use "finally" here.
- task.formatContext = previousFormatContext;
- task.legacyContext = previousLegacyContext;
- task.context = previousContext;
- task.keyPath = previousKeyPath;
- task.treeContext = previousTreeContext;
- // Restore all active ReactContexts to what they were before.
- switchContext(previousContext);
- if (__DEV__) {
- task.componentStack = previousComponentStack;
+ if (
+ enablePostpone &&
+ request.trackedPostpones !== null &&
+ x.$$typeof === REACT_POSTPONE_TYPE &&
+ task.blockedBoundary !== null // TODO: Support holes in the shell
+ ) {
+ // If we're tracking postpones, we inject a hole here and continue rendering
+ // sibling. Similar to suspending. If we're not tracking, we treat it more like
+ // an error. Notably this doesn't spawn a new task since nothing will fill it
+ // in during this prerender.
+ const postponeInstance: Postpone = (x: any);
+ const trackedPostpones = request.trackedPostpones;
+ const postponedSegment = injectPostponedHole(
+ request,
+ ((task: any): RenderTask), // We don't use ReplayTasks in prerenders.
+ postponeInstance.message,
+ );
+ trackPostpone(request, trackedPostpones, task, postponedSegment);
+
+ // Restore the context. We assume that this will be restored by the inner
+ // functions in case nothing throws so we don't use "finally" here.
+ task.formatContext = previousFormatContext;
+ task.legacyContext = previousLegacyContext;
+ task.context = previousContext;
+ task.keyPath = previousKeyPath;
+ task.treeContext = previousTreeContext;
+ // Restore all active ReactContexts to what they were before.
+ switchContext(previousContext);
+ if (__DEV__) {
+ task.componentStack = previousComponentStack;
+ }
+ return;
}
- return;
}
}
- // Restore the context. We assume that this will be restored by the inner
- // functions in case nothing throws so we don't use "finally" here.
- task.formatContext = previousFormatContext;
- task.legacyContext = previousLegacyContext;
- task.context = previousContext;
- task.keyPath = previousKeyPath;
- task.treeContext = previousTreeContext;
- // Restore all active ReactContexts to what they were before.
- switchContext(previousContext);
- if (__DEV__) {
- task.componentStack = previousComponentStack;
- }
- // We assume that we don't need the correct context.
- // Let's terminate the rest of the tree and don't render any siblings.
- throw x;
}
+ // Restore the context. We assume that this will be restored by the inner
+ // functions in case nothing throws so we don't use "finally" here.
+ task.formatContext = previousFormatContext;
+ task.legacyContext = previousLegacyContext;
+ task.context = previousContext;
+ task.keyPath = previousKeyPath;
+ task.treeContext = previousTreeContext;
+ // Restore all active ReactContexts to what they were before.
+ switchContext(previousContext);
+ if (__DEV__) {
+ task.componentStack = previousComponentStack;
+ }
+ // We assume that we don't need the correct context.
+ // Let's terminate the rest of the tree and don't render any siblings.
+ throw x;
}
function erroredTask(
request: Request,
boundary: Root | SuspenseBoundary,
- segment: Segment,
error: mixed,
) {
// Report the error to a global handler.
@@ -2214,6 +2956,9 @@ function erroredTask(
errorDigest = logRecoverableError(request, error);
}
if (boundary === null) {
+ // TODO: If the shell errors during a replay, that's not a fatal error. Instead
+ // we should be able to recover by client rendering all the root boundaries in
+ // the ReplaySet and any already matched.
fatalError(request, error);
} else {
boundary.pendingTasks--;
@@ -2250,8 +2995,17 @@ function abortTaskSoft(this: Request, task: Task): void {
const request: Request = this;
const boundary = task.blockedBoundary;
const segment = task.blockedSegment;
- segment.status = ABORTED;
- finishedTask(request, boundary, segment);
+ if (segment !== null) {
+ segment.status = ABORTED;
+ finishedTask(request, boundary, segment);
+ }
+}
+
+function abortRemainingResumableNodes(
+ nodes: Array,
+ error: mixed,
+): void {
+ // TODO: Abort any undiscovered Suspense boundaries in the ReplaySet.
}
function abortTask(task: Task, request: Request, error: mixed): void {
@@ -2259,7 +3013,16 @@ function abortTask(task: Task, request: Request, error: mixed): void {
// client rendered mode.
const boundary = task.blockedBoundary;
const segment = task.blockedSegment;
- segment.status = ABORTED;
+ if (segment === null) {
+ // $FlowFixMe: Refined.
+ const replay: ReplaySet = task.replay;
+ replay.pendingTasks--;
+ if (replay.pendingTasks === 0) {
+ abortRemainingResumableNodes(replay.nodes, error);
+ }
+ } else {
+ segment.status = ABORTED;
+ }
if (boundary === null) {
request.allPendingTasks--;
@@ -2319,9 +3082,7 @@ function queueCompletedSegment(
if (
segment.chunks.length === 0 &&
segment.children.length === 1 &&
- segment.children[0].boundary === null &&
- // Typically the id would not be assigned yet but if it's a postponed segment it might be.
- segment.children[0].id === -1
+ segment.children[0].boundary === null
) {
// This is an empty segment. There's nothing to write, so we can instead transfer the ID
// to the child. That way any existing references point to the child.
@@ -2340,10 +3101,10 @@ function queueCompletedSegment(
function finishedTask(
request: Request,
boundary: Root | SuspenseBoundary,
- segment: Segment,
+ segment: null | Segment,
) {
if (boundary === null) {
- if (segment.parentFlushed) {
+ if (segment !== null && segment.parentFlushed) {
if (request.completedRootSegment !== null) {
throw new Error(
'There can only be one root segment. This is a bug in React.',
@@ -2368,7 +3129,7 @@ function finishedTask(
boundary.status = COMPLETED;
}
// This must have been the last segment we were waiting on. This boundary is now complete.
- if (segment.parentFlushed) {
+ if (segment !== null && segment.parentFlushed) {
// Our parent segment already flushed, so we need to schedule this segment to be emitted.
// If it is a segment that was aborted, we'll write other content instead so we don't need
// to emit it.
@@ -2388,7 +3149,7 @@ function finishedTask(
boundary.fallbackAbortableTasks.forEach(abortTaskSoft, request);
boundary.fallbackAbortableTasks.clear();
} else {
- if (segment.parentFlushed) {
+ if (segment !== null && segment.parentFlushed) {
// Our parent already flushed, so we need to schedule this segment to be emitted.
// If it is a segment that was aborted, we'll write other content instead so we don't need
// to emit it.
@@ -2426,6 +3187,27 @@ function retryTask(request: Request, task: Task): void {
);
}
const segment = task.blockedSegment;
+ if (segment === null) {
+ retryReplayTask(
+ request,
+ // $FlowFixMe: Refined.
+ task,
+ );
+ } else {
+ retryRenderTask(
+ request,
+ // $FlowFixMe: Refined.
+ task,
+ segment,
+ );
+ }
+}
+
+function retryRenderTask(
+ request: Request,
+ task: RenderTask,
+ segment: Segment,
+): void {
if (segment.status !== PENDING) {
// We completed this by other means before we had a chance to retry it.
return;
@@ -2514,7 +3296,82 @@ function retryTask(request: Request, task: Task): void {
}
task.abortSet.delete(task);
segment.status = ERRORED;
- erroredTask(request, task.blockedBoundary, segment, x);
+ erroredTask(request, task.blockedBoundary, x);
+ return;
+ } finally {
+ if (enableFloat) {
+ setCurrentlyRenderingBoundaryResourcesTarget(request.renderState, null);
+ }
+ if (__DEV__) {
+ currentTaskInDEV = prevTaskInDEV;
+ }
+ }
+}
+
+function retryReplayTask(request: Request, task: ReplayTask): void {
+ if (task.replay.pendingTasks === 0) {
+ // There are no pending tasks working on this set, so we must have aborted.
+ return;
+ }
+
+ // We restore the context to what it was when we suspended.
+ // We don't restore it after we leave because it's likely that we'll end up
+ // needing a very similar context soon again.
+ switchContext(task.context);
+ let prevTaskInDEV = null;
+ if (__DEV__) {
+ prevTaskInDEV = currentTaskInDEV;
+ currentTaskInDEV = task;
+ }
+
+ try {
+ // We call the destructive form that mutates this task. That way if something
+ // suspends again, we can reuse the same task instead of spawning a new one.
+
+ // Reset the task's thenable state before continuing, so that if a later
+ // component suspends we can reuse the same task object. If the same
+ // component suspends again, the thenable state will be restored.
+ const prevThenableState = task.thenableState;
+ task.thenableState = null;
+
+ renderNodeDestructive(request, task, prevThenableState, task.node, -1);
+
+ if (task.replay.pendingTasks === 1 && task.replay.nodes.length > 0) {
+ throw new Error(
+ "Couldn't find all resumable slots by key/index during replaying. " +
+ "The tree doesn't match so React will fallback to client rendering.",
+ );
+ }
+ task.replay.pendingTasks--;
+
+ task.abortSet.delete(task);
+ finishedTask(request, task.blockedBoundary, null);
+ } catch (thrownValue) {
+ resetHooksState();
+
+ const x =
+ thrownValue === SuspenseException
+ ? // This is a special type of exception used for Suspense. For historical
+ // reasons, the rest of the Suspense implementation expects the thrown
+ // value to be a thenable, because before `use` existed that was the
+ // (unstable) API for suspending. This implementation detail can change
+ // later, once we deprecate the old API in favor of `use`.
+ getSuspendedThenable()
+ : thrownValue;
+
+ 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;
+ }
+ }
+ task.replay.pendingTasks--;
+ task.abortSet.delete(task);
+ erroredTask(request, task.blockedBoundary, x);
return;
} finally {
if (enableFloat) {
@@ -3012,7 +3869,9 @@ function flushCompletedQueues(
if (
!enablePostpone ||
request.trackedPostpones === null ||
- request.trackedPostpones.root.length === 0
+ // We check the working map instead of the root because the root could've
+ // been mutated at this point if it was passed straight through to resume().
+ request.trackedPostpones.workingMap.size === 0
) {
writePostamble(destination, request.resumableState);
}
diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json
index 4eaed23f7cc00..3f7bc8d47a875 100644
--- a/scripts/error-codes/codes.json
+++ b/scripts/error-codes/codes.json
@@ -471,5 +471,9 @@
"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.",
"485": "Cannot update form state while rendering.",
- "486": "It should not be possible to postpone at the root. This is a bug in React."
+ "486": "It should not be possible to postpone at the root. This is a bug in React.",
+ "487": "Did not expect to see a Suspense boundary in this slot. The tree doesn't match so React will fallback to client rendering.",
+ "488": "Couldn't find all resumable slots by key/index during replaying. The tree doesn't match so React will fallback to client rendering.",
+ "489": "Expected to see a component of type \"%s\" in this slot. The tree doesn't match so React will fallback to client rendering.",
+ "490": "Expected to see a Suspense boundary in this slot. The tree doesn't match so React will fallback to client rendering."
}
\ No newline at end of file