@samitouri / QOS-React-2 / commits / ac7820a99e

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 });