[Fizz] Detatch boundary after flushing segment with boundary (#34694)
When we flush a Suspense boundary we might not flush the fallback segment, it might only flush a placeholder instead. In this case the segment can flush again but we do not want to flush the boundary itself a second time. We now detach the boundary after flushing it. better solution to: https://github.com/facebook/react/pull/34668
Josh Story committed
Oct 2, 2025 at 13:21 UTC
7d9f876cbc7e9363092e60436704cf8ae435b969
2 files changed
+58
-2
packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
+52
@@ -10832,4 +10832,56 @@ Unfortunately that previous paragraph wasn't quite long enough so I'll continue
10832
</html>,
10833
);
10834
});
10835
+
10836
+ it('not error when a suspended fallback segment directly inside another Suspense is abandoned', async () => {
10837
+ function SuspendForever() {
10838
+ React.use(new Promise(() => {}));
10839
+ }
10840
+
10841
+ let resolve = () => {};
10842
+ const suspendPromise = new Promise(r => {
10843
+ resolve = r;
10844
+ });
10845
+ function Suspend() {
10846
+ return React.use(suspendPromise);
10847
+ }
10848
+
10849
+ function App() {
10850
+ return (
10851
+ <html>
10852
+ <body>
10853
+ <Suspense fallback="outer">
10854
+ <Suspense fallback={<SuspendForever />}>
10855
+ <span>hello world</span>
10856
+ <span>
10857
+ <Suspend />
10858
+ </span>
10859
+ </Suspense>
10860
+ </Suspense>
10861
+ </body>
10862
+ </html>
10863
+ );
10864
+ }
10865
+
10866
+ await act(async () => {
10867
+ const {pipe} = renderToPipeableStream(<App />, {
10868
+ onError() {},
10869
+ });
10870
+ pipe(writable);
10871
+ });
10872
+
10873
+ await act(() => {
10874
+ resolve('!');
10875
+ });
10876
+
10877
+ expect(getVisibleChildren(document)).toEqual(
10878
+ <html>
10879
+ <head />
10880
+ <body>
10881
+ <span>hello world</span>
10882
+ <span>!</span>
10883
+ </body>
10884
+ </html>,
10885
+ );
10886
+ });
10887
});
packages/react-server/src/ReactFizzServer.js
+6
-2
@@ -342,7 +342,7 @@ type Segment = {
342
// The context that this segment was created in.
343
parentFormatContext: FormatContext,
344
// If this segment represents a fallback, this is the content that will replace that fallback.
345
- +boundary: null | SuspenseBoundary,
345
+ boundary: null | SuspenseBoundary,
346
// used to discern when text separator boundaries are needed
347
lastPushedText: boolean,
348
textEmbedded: boolean,
@@ -5681,6 +5681,10 @@ function flushSegment(
5681
return flushSubtree(request, destination, segment, hoistableState);
5682
}
5683
5684
+ // We're going to write the boundary. We don't need to maintain this reference since
5685
+ // we might reflush this segment at a later time (if it aborts and we inlined) but
5686
+ // we don't want to reflush the boundary
5687
+ segment.boundary = null;
5688
boundary.parentFlushed = true;
5689
// This segment is a Suspense boundary. We need to decide whether to
5690
// emit the content or the fallback now.
@@ -5952,7 +5956,7 @@ function flushPartiallyCompletedSegment(
5956
segment: Segment,
5957
): boolean {
5958
if (segment.status === FLUSHED) {
5955
- // We've already flushed this inline.
5959
+ // We've already flushed this inline
5960
return true;
5961
}
5962