Create fresh Offscreen instance when replaying (#34127)
Sebastian "Sebbie" Silbermann committed
Aug 11, 2025 at 20:55 UTC
ac7820a99efac29dcd5a69f6d2438f6d31b7abbf
3 files changed
+78
-36
packages/react-reconciler/src/ReactFiber.js
-18
@@ -24,7 +24,6 @@ import type {ActivityInstance, SuspenseInstance} from './ReactFiberConfig';
24
import type {
25
LegacyHiddenProps,
26
OffscreenProps,
27
- OffscreenInstance,
27
} from './ReactFiberOffscreenComponent';
28
import type {ViewTransitionState} from './ReactFiberViewTransitionComponent';
29
import type {TracingMarkerInstance} from './ReactFiberTracingMarkerComponent';
@@ -76,7 +75,6 @@ import {
75
ViewTransitionComponent,
76
ActivityComponent,
77
} from './ReactWorkTags';
79
-import {OffscreenVisible} from './ReactFiberOffscreenComponent';
78
import {getComponentNameFromOwner} from 'react-reconciler/src/getComponentNameFromFiber';
79
import {isDevToolsPresent} from './ReactFiberDevToolsHook';
80
import {
@@ -831,13 +829,6 @@ export function createFiberFromOffscreen(
829
): Fiber {
830
const fiber = createFiber(OffscreenComponent, pendingProps, key, mode);
831
fiber.lanes = lanes;
834
- const primaryChildInstance: OffscreenInstance = {
835
- _visibility: OffscreenVisible,
836
- _pendingMarkers: null,
837
- _retryCache: null,
838
- _transitions: null,
839
- };
840
- fiber.stateNode = primaryChildInstance;
832
return fiber;
833
}
834
export function createFiberFromActivity(
@@ -885,15 +876,6 @@ export function createFiberFromLegacyHidden(
876
const fiber = createFiber(LegacyHiddenComponent, pendingProps, key, mode);
877
fiber.elementType = REACT_LEGACY_HIDDEN_TYPE;
878
fiber.lanes = lanes;
888
- // Adding a stateNode for legacy hidden because it's currently using
889
- // the offscreen implementation, which depends on a state node
890
- const instance: OffscreenInstance = {
891
- _visibility: OffscreenVisible,
892
- _pendingMarkers: null,
893
- _transitions: null,
894
- _retryCache: null,
895
- };
896
- fiber.stateNode = instance;
879
return fiber;
880
}
881
packages/react-reconciler/src/ReactFiberBeginWork.js
+54
-18
@@ -280,6 +280,7 @@ import {
280
createCapturedValueFromError,
281
createCapturedValueAtFiber,
282
} from './ReactCapturedValue';
283
+import {OffscreenVisible} from './ReactFiberOffscreenComponent';
284
import {
285
createClassErrorUpdate,
286
initializeClassErrorUpdate,
@@ -620,6 +621,18 @@ function updateOffscreenComponent(
621
const prevState: OffscreenState | null =
622
current !== null ? current.memoizedState : null;
623
624
+ if (current === null && workInProgress.stateNode === null) {
625
+ // We previously reset the work-in-progress.
626
+ // We need to create a new Offscreen instance.
627
+ const primaryChildInstance: OffscreenInstance = {
628
+ _visibility: OffscreenVisible,
629
+ _pendingMarkers: null,
630
+ _retryCache: null,
631
+ _transitions: null,
632
+ };
633
+ workInProgress.stateNode = primaryChildInstance;
634
+ }
635
+
636
if (
637
nextProps.mode === 'hidden' ||
638
(enableLegacyHidden && nextProps.mode === 'unstable-defer-without-hiding')
@@ -788,6 +801,26 @@ function updateOffscreenComponent(
801
return workInProgress.child;
802
}
803
804
+function bailoutOffscreenComponent(
805
+ current: Fiber | null,
806
+ workInProgress: Fiber,
807
+): Fiber | null {
808
+ if (
809
+ (current === null || current.tag !== OffscreenComponent) &&
810
+ workInProgress.stateNode === null
811
+ ) {
812
+ const primaryChildInstance: OffscreenInstance = {
813
+ _visibility: OffscreenVisible,
814
+ _pendingMarkers: null,
815
+ _retryCache: null,
816
+ _transitions: null,
817
+ };
818
+ workInProgress.stateNode = primaryChildInstance;
819
+ }
820
+
821
+ return workInProgress.sibling;
822
+}
823
+
824
function deferHiddenOffscreenComponent(
825
current: Fiber | null,
826
workInProgress: Fiber,
@@ -1095,9 +1128,13 @@ function updateActivityComponent(
1128
if (nextProps.mode === 'hidden') {
1129
// SSR doesn't render hidden Activity so it shouldn't hydrate,
1130
// even at offscreen lane. Defer to a client rendered offscreen lane.
1098
- mountActivityChildren(workInProgress, nextProps, renderLanes);
1131
+ const primaryChildFragment = mountActivityChildren(
1132
+ workInProgress,
1133
+ nextProps,
1134
+ renderLanes,
1135
+ );
1136
workInProgress.lanes = laneToLanes(OffscreenLane);
1100
- return null;
1137
+ return bailoutOffscreenComponent(null, primaryChildFragment);
1138
} else {
1139
// We must push the suspense handler context *before* attempting to
1140
// hydrate, to avoid a mismatch in case it errors.
@@ -2373,7 +2410,7 @@ function updateSuspenseComponent(
2410
if (showFallback) {
2411
pushFallbackTreeSuspenseHandler(workInProgress);
2412
2376
- const fallbackFragment = mountSuspenseFallbackChildren(
2413
+ mountSuspenseFallbackChildren(
2414
workInProgress,
2415
nextPrimaryChildren,
2416
nextFallbackChildren,
@@ -2408,7 +2445,7 @@ function updateSuspenseComponent(
2445
}
2446
}
2447
2411
- return fallbackFragment;
2448
+ return bailoutOffscreenComponent(null, primaryChildFragment);
2449
} else if (
2450
enableCPUSuspense &&
2451
typeof nextProps.unstable_expectedLoadTime === 'number'
@@ -2417,7 +2454,7 @@ function updateSuspenseComponent(
2454
// unblock the surrounding content. Then immediately retry after the
2455
// initial commit.
2456
pushFallbackTreeSuspenseHandler(workInProgress);
2420
- const fallbackFragment = mountSuspenseFallbackChildren(
2457
+ mountSuspenseFallbackChildren(
2458
workInProgress,
2459
nextPrimaryChildren,
2460
nextFallbackChildren,
@@ -2444,7 +2481,7 @@ function updateSuspenseComponent(
2481
// RetryLane even if it's the one currently rendering since we're leaving
2482
// it behind on this node.
2483
workInProgress.lanes = SomeRetryLane;
2447
- return fallbackFragment;
2484
+ return bailoutOffscreenComponent(null, primaryChildFragment);
2485
} else {
2486
pushPrimaryTreeSuspenseHandler(workInProgress);
2487
return mountSuspensePrimaryChildren(
@@ -2479,7 +2516,7 @@ function updateSuspenseComponent(
2516
2517
const nextFallbackChildren = nextProps.fallback;
2518
const nextPrimaryChildren = nextProps.children;
2482
- const fallbackChildFragment = updateSuspenseFallbackChildren(
2519
+ updateSuspenseFallbackChildren(
2520
current,
2521
workInProgress,
2522
nextPrimaryChildren,
@@ -2532,7 +2569,7 @@ function updateSuspenseComponent(
2569
renderLanes,
2570
);
2571
workInProgress.memoizedState = SUSPENDED_MARKER;
2535
- return fallbackChildFragment;
2572
+ return bailoutOffscreenComponent(current.child, primaryChildFragment);
2573
} else {
2574
if (
2575
prevState !== null &&
@@ -2788,7 +2825,7 @@ function updateSuspenseFallbackChildren(
2825
primaryChildFragment.sibling = fallbackChildFragment;
2826
workInProgress.child = primaryChildFragment;
2827
2791
- return fallbackChildFragment;
2828
+ return bailoutOffscreenComponent(null, primaryChildFragment);
2829
}
2830
2831
function retrySuspenseComponentWithoutHydrating(
@@ -3094,14 +3131,13 @@ function updateDehydratedSuspenseComponent(
3131
3132
const nextPrimaryChildren = nextProps.children;
3133
const nextFallbackChildren = nextProps.fallback;
3097
- const fallbackChildFragment =
3098
- mountSuspenseFallbackAfterRetryWithoutHydrating(
3099
- current,
3100
- workInProgress,
3101
- nextPrimaryChildren,
3102
- nextFallbackChildren,
3103
- renderLanes,
3104
- );
3134
+ mountSuspenseFallbackAfterRetryWithoutHydrating(
3135
+ current,
3136
+ workInProgress,
3137
+ nextPrimaryChildren,
3138
+ nextFallbackChildren,
3139
+ renderLanes,
3140
+ );
3141
const primaryChildFragment: Fiber = (workInProgress.child: any);
3142
primaryChildFragment.memoizedState =
3143
mountSuspenseOffscreenState(renderLanes);
@@ -3111,7 +3147,7 @@ function updateDehydratedSuspenseComponent(
3147
renderLanes,
3148
);
3149
workInProgress.memoizedState = SUSPENDED_MARKER;
3114
- return fallbackChildFragment;
3150
+ return bailoutOffscreenComponent(null, primaryChildFragment);
3151
}
3152
}
3153
}
packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.js
+24
@@ -4141,4 +4141,28 @@ describe('ReactSuspenseWithNoopRenderer', () => {
4141
</>,
4142
);
4143
});
4144
+
4145
+ it('can rerender after resolving a promise', async () => {
4146
+ const promise = Promise.resolve(null);
4147
+ const root = ReactNoop.createRoot();
4148
+
4149
+ await act(() => {
4150
+ startTransition(() => {
4151
+ root.render(<Suspense>{promise}</Suspense>);
4152
+ });
4153
+ });
4154
+
4155
+ assertLog([]);
4156
+ expect(root).toMatchRenderedOutput(null);
4157
+
4158
+ await act(() => {
4159
+ startTransition(() => {
4160
+ root.render(
4161
+ <Suspense>
4162
+ <div />
4163
+ </Suspense>,
4164
+ );
4165
+ });
4166
+ });
4167
+ });
4168
});