[Fizz] Track boundaries in future rows as postponed (#33329)
Follow up to #33321. We can mark boundaries that were blocked in the prerender as postponed but without anything to replayed inside them. That way they're not emitted in the prerender but is unblocked when replayed. Technically this does some unnecessary replaying of the path to the otherwise already completed boundary but it simplifies our model by just marking the boundary as needing replaying.
Sebastian Markbåge committed
May 22, 2025 at 10:20 UTC
99781d605b6a6fd332bba654ac0c328e41df888c
2 files changed
+87
-48
packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js
+5
-1
@@ -2262,6 +2262,7 @@ describe('ReactDOMFizzStaticBrowser', () => {
2262
<ComponentB />
2263
</Suspense>
2264
<Suspense fallback="Loading C">C</Suspense>
2265
+ <Suspense fallback="Loading D">D</Suspense>
2266
</SuspenseList>
2267
</div>
2268
);
@@ -2282,6 +2283,7 @@ describe('ReactDOMFizzStaticBrowser', () => {
2283
});
2284
2285
const prerendered = await pendingResult;
2286
+
2287
const postponedState = JSON.stringify(prerendered.postponed);
2288
2289
await readIntoContainer(prerendered.prelude);
@@ -2289,7 +2291,8 @@ describe('ReactDOMFizzStaticBrowser', () => {
2291
<div>
2292
{'Loading A'}
2293
{'Loading B'}
2292
- {'C' /* TODO: This should not be resolved. */}
2294
+ {'Loading C'}
2295
+ {'Loading D'}
2296
</div>,
2297
);
2298
@@ -2309,6 +2312,7 @@ describe('ReactDOMFizzStaticBrowser', () => {
2312
{'A'}
2313
{'B'}
2314
{'C'}
2315
+ {'D'}
2316
</div>,
2317
);
2318
});
packages/react-server/src/ReactFizzServer.js
+82
-47
@@ -1725,6 +1725,26 @@ function unblockSuspenseListRow(
1725
}
1726
}
1727
1728
+function trackPostponedSuspenseListRow(
1729
+ request: Request,
1730
+ trackedPostpones: PostponedHoles,
1731
+ postponedRow: null | SuspenseListRow,
1732
+): void {
1733
+ // TODO: Because we unconditionally call this, it will be called by finishedTask
1734
+ // and so ends up recursive which can lead to stack overflow for very long lists.
1735
+ if (postponedRow !== null) {
1736
+ const postponedBoundaries = postponedRow.boundaries;
1737
+ if (postponedBoundaries !== null) {
1738
+ postponedRow.boundaries = null;
1739
+ for (let i = 0; i < postponedBoundaries.length; i++) {
1740
+ const postponedBoundary = postponedBoundaries[i];
1741
+ trackPostponedBoundary(request, trackedPostpones, postponedBoundary);
1742
+ finishedTask(request, postponedBoundary, null, null);
1743
+ }
1744
+ }
1745
+ }
1746
+}
1747
+
1748
function tryToResolveTogetherRow(
1749
request: Request,
1750
togetherRow: SuspenseListRow,
@@ -3774,6 +3794,49 @@ function renderChildrenArray(
3794
}
3795
}
3796
3797
+function trackPostponedBoundary(
3798
+ request: Request,
3799
+ trackedPostpones: PostponedHoles,
3800
+ boundary: SuspenseBoundary,
3801
+): ReplaySuspenseBoundary {
3802
+ boundary.status = POSTPONED;
3803
+ // We need to eagerly assign it an ID because we'll need to refer to
3804
+ // it before flushing and we know that we can't inline it.
3805
+ boundary.rootSegmentID = request.nextSegmentId++;
3806
+
3807
+ const boundaryKeyPath = boundary.trackedContentKeyPath;
3808
+ if (boundaryKeyPath === null) {
3809
+ throw new Error(
3810
+ 'It should not be possible to postpone at the root. This is a bug in React.',
3811
+ );
3812
+ }
3813
+
3814
+ const fallbackReplayNode = boundary.trackedFallbackNode;
3815
+
3816
+ const children: Array<ReplayNode> = [];
3817
+ const boundaryNode: void | ReplayNode =
3818
+ trackedPostpones.workingMap.get(boundaryKeyPath);
3819
+ if (boundaryNode === undefined) {
3820
+ const suspenseBoundary: ReplaySuspenseBoundary = [
3821
+ boundaryKeyPath[1],
3822
+ boundaryKeyPath[2],
3823
+ children,
3824
+ null,
3825
+ fallbackReplayNode,
3826
+ boundary.rootSegmentID,
3827
+ ];
3828
+ trackedPostpones.workingMap.set(boundaryKeyPath, suspenseBoundary);
3829
+ addToReplayParent(suspenseBoundary, boundaryKeyPath[0], trackedPostpones);
3830
+ return suspenseBoundary;
3831
+ } else {
3832
+ // Upgrade to ReplaySuspenseBoundary.
3833
+ const suspenseBoundary: ReplaySuspenseBoundary = (boundaryNode: any);
3834
+ suspenseBoundary[4] = fallbackReplayNode;
3835
+ suspenseBoundary[5] = boundary.rootSegmentID;
3836
+ return suspenseBoundary;
3837
+ }
3838
+}
3839
+
3840
function trackPostpone(
3841
request: Request,
3842
trackedPostpones: PostponedHoles,
@@ -3796,22 +3859,12 @@ function trackPostpone(
3859
}
3860
3861
if (boundary !== null && boundary.status === PENDING) {
3799
- boundary.status = POSTPONED;
3800
- // We need to eagerly assign it an ID because we'll need to refer to
3801
- // it before flushing and we know that we can't inline it.
3802
- boundary.rootSegmentID = request.nextSegmentId++;
3803
-
3804
- const boundaryKeyPath = boundary.trackedContentKeyPath;
3805
- if (boundaryKeyPath === null) {
3806
- throw new Error(
3807
- 'It should not be possible to postpone at the root. This is a bug in React.',
3808
- );
3809
- }
3810
-
3811
- const fallbackReplayNode = boundary.trackedFallbackNode;
3812
-
3813
- const children: Array<ReplayNode> = [];
3814
- if (boundaryKeyPath === keyPath && task.childIndex === -1) {
3862
+ const boundaryNode = trackPostponedBoundary(
3863
+ request,
3864
+ trackedPostpones,
3865
+ boundary,
3866
+ );
3867
+ if (boundary.trackedContentKeyPath === keyPath && task.childIndex === -1) {
3868
// Assign ID
3869
if (segment.id === -1) {
3870
if (segment.parentFlushed) {
@@ -3823,39 +3876,10 @@ function trackPostpone(
3876
}
3877
}
3878
// We postponed directly inside the Suspense boundary so we mark this for resuming.
3826
- const boundaryNode: ReplaySuspenseBoundary = [
3827
- boundaryKeyPath[1],
3828
- boundaryKeyPath[2],
3829
- children,
3830
- segment.id,
3831
- fallbackReplayNode,
3832
- boundary.rootSegmentID,
3833
- ];
3834
- trackedPostpones.workingMap.set(boundaryKeyPath, boundaryNode);
3835
- addToReplayParent(boundaryNode, boundaryKeyPath[0], trackedPostpones);
3879
+ boundaryNode[3] = segment.id;
3880
return;
3837
- } else {
3838
- let boundaryNode: void | ReplayNode =
3839
- trackedPostpones.workingMap.get(boundaryKeyPath);
3840
- if (boundaryNode === undefined) {
3841
- boundaryNode = [
3842
- boundaryKeyPath[1],
3843
- boundaryKeyPath[2],
3844
- children,
3845
- null,
3846
- fallbackReplayNode,
3847
- boundary.rootSegmentID,
3848
- ];
3849
- trackedPostpones.workingMap.set(boundaryKeyPath, boundaryNode);
3850
- addToReplayParent(boundaryNode, boundaryKeyPath[0], trackedPostpones);
3851
- } else {
3852
- // Upgrade to ReplaySuspenseBoundary.
3853
- const suspenseBoundary: ReplaySuspenseBoundary = (boundaryNode: any);
3854
- suspenseBoundary[4] = fallbackReplayNode;
3855
- suspenseBoundary[5] = boundary.rootSegmentID;
3856
- }
3857
- // Fall through to add the child node.
3881
}
3882
+ // Otherwise, fall through to add the child node.
3883
}
3884
3885
// We know that this will leave a hole so we might as well assign an ID now.
@@ -4941,7 +4965,18 @@ function finishedTask(
4965
} else if (boundary.status === POSTPONED) {
4966
const boundaryRow = boundary.row;
4967
if (boundaryRow !== null) {
4968
+ if (request.trackedPostpones !== null) {
4969
+ // If this boundary is postponed, then we need to also postpone any blocked boundaries
4970
+ // in the next row.
4971
+ trackPostponedSuspenseListRow(
4972
+ request,
4973
+ request.trackedPostpones,
4974
+ boundaryRow.next,
4975
+ );
4976
+ }
4977
if (--boundaryRow.pendingTasks === 0) {
4978
+ // This is really unnecessary since we've already postponed the boundaries but
4979
+ // for pairity with other track+finish paths. We might end up using the hoisting.
4980
finishSuspenseListRow(request, boundaryRow);
4981
}
4982
}