@samitouri / QOS-React-1 / commits / 756fdd476e

[Fiber] Don't reaquire HostSingletons during dev effect validation (#37113)

Stacked on #37112 In dev effects are validated using a double-invoke technique. To do this the effects are destroyed and recreated during a validation traversal. However this same disappear and reappear path is used when Offscreen Fibers go hidden and HostSingletons have unique behavior when going hidden. So now that the double invoke effects process happens during hydration it is more common to have your Singletons be released and acquired during this validation phase which is observable most notably by having extra attributes removed from them. The prior commits in this stack deal with preserving non-react owned attributes on release however it is semantically incorrect to release and acquire the singleton during this validation because it isn't really an effect, it simply lives in this traversal to avoid having to do another traversal during the commit. This change adds a bit of info to the release and acquire path to only conditionally perform the necessary reacquire flow if we are not in the validation phase.

Josh Story committed Jul 27, 2026 at 09:15 UTC 756fdd476e82b0cad3776d7de083250b20f86524
3 files changed +176 -45
packages/react-dom/src/__tests__/ReactDOMSingletonComponents-test.js
+48
@@ -491,6 +491,54 @@ describe('ReactDOM HostSingleton', () => {
491 expect(onScroll).toHaveBeenCalledTimes(2);
492 });
493
494 + // @gate __DEV__
495 + it('does not release or reacquire singletons when double invoking effects during hydration', async () => {
496 + const effectLog = [];
497 + const html = '<span id="managed">managed content</span>';
498 +
499 + function Effect() {
500 + React.useLayoutEffect(() => {
501 + effectLog.push('mount');
502 + return () => {
503 + effectLog.push('unmount');
504 + };
505 + }, []);
506 + return <meta name="strict-effect" />;
507 + }
508 +
509 + await actIntoEmptyDocument(() => {
510 + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
511 + <html>
512 + <head>
513 + <meta name="strict-effect" />
514 + </head>
515 + <body dangerouslySetInnerHTML={{__html: html}} />
516 + </html>,
517 + );
518 + pipe(writable);
519 + });
520 + const serverBodyHTML = document.body.innerHTML;
521 + const managedElement = document.getElementById('managed');
522 +
523 + ReactDOMClient.hydrateRoot(
524 + document,
525 + <React.StrictMode>
526 + <html>
527 + <head>
528 + <Effect />
529 + </head>
530 + <body dangerouslySetInnerHTML={{__html: serverBodyHTML}} />
531 + </html>
532 + </React.StrictMode>,
533 + );
534 + await waitForAll([]);
535 +
536 + // The Strict Mode effects are still double invoked.
537 + expect(effectLog).toEqual(['mount', 'unmount', 'mount']);
538 + // Hydrating a matching tree should preserve the server-rendered nodes.
539 + expect(document.getElementById('managed')).toBe(managedElement);
540 + });
541 +
542 it('renders into html, head, and body persistently so the node identities never change and extraneous styles are retained', async () => {
543 // Server render some html that will get replaced with a client render
544 await actIntoEmptyDocument(() => {
packages/react-reconciler/src/ReactFiberCommitWork.js
+124 -41
@@ -288,6 +288,12 @@ import {
288 import {markIndicatorHandled} from './ReactFiberRootScheduler';
289 import type {Flags} from './ReactFiberFlags';
290
291 +type LayoutEffectTraversalFlags = number;
292 +
293 +const NoLayoutEffectTraversalFlags = /* */ 0b00;
294 +const IncludeWorkInProgressEffects = /* */ 0b01;
295 +const IncludeHostSingletons = /* */ 0b10;
296 +
297 // Used during the commit phase to track the state of the Offscreen component stack.
298 // Allows us to avoid traversing the return path to find the nearest Offscreen ancestor.
299 let offscreenSubtreeIsHidden: boolean = false;
@@ -795,12 +801,20 @@ function commitLayoutEffectOnFiber(
801 // traversing the layout effects, we must also re-mount layout
802 // effects that were unmounted when the Offscreen subtree was
803 // hidden. So this is a superset of the normal commitLayoutEffects.
798 - const includeWorkInProgressEffects =
799 - (finishedWork.subtreeFlags & LayoutMask) !== NoFlags;
804 + let layoutEffectTraversalFlags: LayoutEffectTraversalFlags;
805 + // $FlowFixMe[constant-condition]
806 + if (supportsSingletons) {
807 + layoutEffectTraversalFlags = IncludeHostSingletons;
808 + } else {
809 + layoutEffectTraversalFlags = NoLayoutEffectTraversalFlags;
810 + }
811 + if ((finishedWork.subtreeFlags & LayoutMask) !== NoFlags) {
812 + layoutEffectTraversalFlags |= IncludeWorkInProgressEffects;
813 + }
814 recursivelyTraverseReappearLayoutEffects(
815 finishedRoot,
816 finishedWork,
803 - includeWorkInProgressEffects,
817 + layoutEffectTraversalFlags,
818 );
819 if (
820 enableProfilerTimer &&
@@ -2581,7 +2595,17 @@ function commitMutationEffectsOnFiber(
2595 (finishedWork.mode & ConcurrentMode) !== NoMode
2596 ) {
2597 // Disappear the layout effects of all the children
2584 - recursivelyTraverseDisappearLayoutEffects(finishedWork);
2598 + let layoutEffectTraversalFlags: LayoutEffectTraversalFlags;
2599 + // $FlowFixMe[constant-condition]
2600 + if (supportsSingletons) {
2601 + layoutEffectTraversalFlags = IncludeHostSingletons;
2602 + } else {
2603 + layoutEffectTraversalFlags = NoLayoutEffectTraversalFlags;
2604 + }
2605 + recursivelyTraverseDisappearLayoutEffects(
2606 + finishedWork,
2607 + layoutEffectTraversalFlags,
2608 + );
2609
2610 if (
2611 enableProfilerTimer &&
@@ -3007,7 +3031,16 @@ function recursivelyTraverseLayoutEffects(
3031 }
3032 }
3033
3010 -export function disappearLayoutEffects(finishedWork: Fiber) {
3034 +export function disappearLayoutEffectsForDEVValidation(finishedWork: Fiber) {
3035 + if (__DEV__) {
3036 + disappearLayoutEffects(finishedWork, NoLayoutEffectTraversalFlags);
3037 + }
3038 +}
3039 +
3040 +function disappearLayoutEffects(
3041 + finishedWork: Fiber,
3042 + layoutEffectTraversalFlags: LayoutEffectTraversalFlags,
3043 +) {
3044 const prevEffectStart = pushComponentEffectStart();
3045 const prevEffectDuration = pushComponentEffectDuration();
3046 const prevEffectErrors = pushComponentEffectErrors();
@@ -3023,7 +3056,10 @@ export function disappearLayoutEffects(finishedWork: Fiber) {
3056 finishedWork.return,
3057 HookLayout,
3058 );
3026 - recursivelyTraverseDisappearLayoutEffects(finishedWork);
3059 + recursivelyTraverseDisappearLayoutEffects(
3060 + finishedWork,
3061 + layoutEffectTraversalFlags,
3062 + );
3063 break;
3064 }
3065 case ClassComponent: {
@@ -3039,14 +3075,22 @@ export function disappearLayoutEffects(finishedWork: Fiber) {
3075 );
3076 }
3077
3042 - recursivelyTraverseDisappearLayoutEffects(finishedWork);
3078 + recursivelyTraverseDisappearLayoutEffects(
3079 + finishedWork,
3080 + layoutEffectTraversalFlags,
3081 + );
3082 break;
3083 }
3084 case HostSingleton: {
3085 // $FlowFixMe[constant-condition]
3086 if (supportsSingletons) {
3048 - // TODO (Offscreen) Check: flags & RefStatic
3049 - commitHostSingletonRelease(finishedWork);
3087 + const includeHostSingletons =
3088 + (layoutEffectTraversalFlags & IncludeHostSingletons) !==
3089 + NoLayoutEffectTraversalFlags;
3090 + if (includeHostSingletons) {
3091 + // TODO (Offscreen) Check: flags & RefStatic
3092 + commitHostSingletonRelease(finishedWork);
3093 + }
3094 }
3095 // Expected fallthrough to HostComponent
3096 }
@@ -3063,7 +3107,10 @@ export function disappearLayoutEffects(finishedWork: Fiber) {
3107 commitFragmentInstanceDeletionEffects(finishedWork);
3108 }
3109
3066 - recursivelyTraverseDisappearLayoutEffects(finishedWork);
3110 + recursivelyTraverseDisappearLayoutEffects(
3111 + finishedWork,
3112 + layoutEffectTraversalFlags,
3113 + );
3114 break;
3115 }
3116 case OffscreenComponent: {
@@ -3072,7 +3119,10 @@ export function disappearLayoutEffects(finishedWork: Fiber) {
3119 // Nested Offscreen tree is already hidden. Don't disappear
3120 // its effects.
3121 } else {
3075 - recursivelyTraverseDisappearLayoutEffects(finishedWork);
3122 + recursivelyTraverseDisappearLayoutEffects(
3123 + finishedWork,
3124 + layoutEffectTraversalFlags,
3125 + );
3126 }
3127 break;
3128 }
@@ -3085,7 +3135,10 @@ export function disappearLayoutEffects(finishedWork: Fiber) {
3135 }
3136 safelyDetachRef(finishedWork, finishedWork.return);
3137 }
3088 - recursivelyTraverseDisappearLayoutEffects(finishedWork);
3138 + recursivelyTraverseDisappearLayoutEffects(
3139 + finishedWork,
3140 + layoutEffectTraversalFlags,
3141 + );
3142 break;
3143 }
3144 case Fragment: {
@@ -3095,7 +3148,10 @@ export function disappearLayoutEffects(finishedWork: Fiber) {
3148 // Fallthrough
3149 }
3150 default: {
3098 - recursivelyTraverseDisappearLayoutEffects(finishedWork);
3151 + recursivelyTraverseDisappearLayoutEffects(
3152 + finishedWork,
3153 + layoutEffectTraversalFlags,
3154 + );
3155 break;
3156 }
3157 }
@@ -3124,23 +3180,41 @@ export function disappearLayoutEffects(finishedWork: Fiber) {
3180 popComponentEffectDidSpawnUpdate(prevEffectDidSpawnUpdate);
3181 }
3182
3127 -function recursivelyTraverseDisappearLayoutEffects(parentFiber: Fiber) {
3183 +function recursivelyTraverseDisappearLayoutEffects(
3184 + parentFiber: Fiber,
3185 + layoutEffectTraversalFlags: LayoutEffectTraversalFlags,
3186 +) {
3187 // TODO (Offscreen) Check: subtreeflags & (RefStatic | LayoutStatic)
3188 let child = parentFiber.child;
3189 while (child !== null) {
3131 - disappearLayoutEffects(child);
3190 + disappearLayoutEffects(child, layoutEffectTraversalFlags);
3191 child = child.sibling;
3192 }
3193 }
3194
3136 -export function reappearLayoutEffects(
3195 +export function reappearLayoutEffectsForDEVValidation(
3196 + finishedRoot: FiberRoot,
3197 + current: Fiber | null,
3198 + finishedWork: Fiber,
3199 +) {
3200 + if (__DEV__) {
3201 + reappearLayoutEffects(
3202 + finishedRoot,
3203 + current,
3204 + finishedWork,
3205 + NoLayoutEffectTraversalFlags,
3206 + );
3207 + }
3208 +}
3209 +
3210 +function reappearLayoutEffects(
3211 finishedRoot: FiberRoot,
3212 current: Fiber | null,
3213 finishedWork: Fiber,
3214 // This function visits both newly finished work and nodes that were re-used
3215 // from a previously committed tree. We cannot check non-static flags if the
3216 // node was reused.
3143 - includeWorkInProgressEffects: boolean,
3217 + layoutEffectTraversalFlags: LayoutEffectTraversalFlags,
3218 ) {
3219 const prevEffectStart = pushComponentEffectStart();
3220 const prevEffectDuration = pushComponentEffectDuration();
@@ -3148,6 +3222,9 @@ export function reappearLayoutEffects(
3222 const prevEffectDidSpawnUpdate = pushComponentEffectDidSpawnUpdate();
3223 // Turn on layout effects in a tree that previously disappeared.
3224 const flags = finishedWork.flags;
3225 + const includeWorkInProgressEffects =
3226 + (layoutEffectTraversalFlags & IncludeWorkInProgressEffects) !==
3227 + NoLayoutEffectTraversalFlags;
3228 switch (finishedWork.tag) {
3229 case FunctionComponent:
3230 case ForwardRef:
@@ -3155,7 +3232,7 @@ export function reappearLayoutEffects(
3232 recursivelyTraverseReappearLayoutEffects(
3233 finishedRoot,
3234 finishedWork,
3158 - includeWorkInProgressEffects,
3235 + layoutEffectTraversalFlags,
3236 );
3237 // TODO: Check flags & LayoutStatic
3238 commitHookLayoutEffects(finishedWork, HookLayout);
@@ -3165,7 +3242,7 @@ export function reappearLayoutEffects(
3242 recursivelyTraverseReappearLayoutEffects(
3243 finishedRoot,
3244 finishedWork,
3168 - includeWorkInProgressEffects,
3245 + layoutEffectTraversalFlags,
3246 );
3247
3248 commitClassDidMount(finishedWork);
@@ -3190,15 +3267,20 @@ export function reappearLayoutEffects(
3267 case HostSingleton: {
3268 // $FlowFixMe[constant-condition]
3269 if (supportsSingletons) {
3193 - // We acquire the singleton instance first so it has appropriate
3194 - // styles before other layout effects run. This isn't perfect because
3195 - // an early sibling of the singleton may have an effect that can
3196 - // observe the singleton before it is acquired.
3197 - // @TODO move this to the mutation phase. The reason it isn't there yet
3198 - // is it seemingly requires an extra traversal because we need to move the
3199 - // disappear effect into a phase before the appear phase
3200 - commitHostSingletonAcquisition(finishedWork);
3201 - // We fall through to the HostComponent case below.
3270 + const includeHostSingletons =
3271 + (layoutEffectTraversalFlags & IncludeHostSingletons) !==
3272 + NoLayoutEffectTraversalFlags;
3273 + if (includeHostSingletons) {
3274 + // We acquire the singleton instance first so it has appropriate
3275 + // styles before other layout effects run. This isn't perfect because
3276 + // an early sibling of the singleton may have an effect that can
3277 + // observe the singleton before it is acquired.
3278 + // @TODO move this to the mutation phase. The reason it isn't there yet
3279 + // is it seemingly requires an extra traversal because we need to move the
3280 + // disappear effect into a phase before the appear phase
3281 + commitHostSingletonAcquisition(finishedWork);
3282 + // We fall through to the HostComponent case below.
3283 + }
3284 }
3285 // Fallthrough
3286 }
@@ -3211,7 +3293,7 @@ export function reappearLayoutEffects(
3293 recursivelyTraverseReappearLayoutEffects(
3294 finishedRoot,
3295 finishedWork,
3214 - includeWorkInProgressEffects,
3296 + layoutEffectTraversalFlags,
3297 );
3298
3299 // Renderers may schedule work to be done after host components are mounted
@@ -3234,7 +3316,7 @@ export function reappearLayoutEffects(
3316 recursivelyTraverseReappearLayoutEffects(
3317 finishedRoot,
3318 finishedWork,
3237 - includeWorkInProgressEffects,
3319 + layoutEffectTraversalFlags,
3320 );
3321
3322 const profilerInstance = finishedWork.stateNode;
@@ -3257,7 +3339,7 @@ export function reappearLayoutEffects(
3339 recursivelyTraverseReappearLayoutEffects(
3340 finishedRoot,
3341 finishedWork,
3260 - includeWorkInProgressEffects,
3342 + layoutEffectTraversalFlags,
3343 );
3344 }
3345 break;
@@ -3266,7 +3348,7 @@ export function reappearLayoutEffects(
3348 recursivelyTraverseReappearLayoutEffects(
3349 finishedRoot,
3350 finishedWork,
3269 - includeWorkInProgressEffects,
3351 + layoutEffectTraversalFlags,
3352 );
3353
3354 if (includeWorkInProgressEffects && flags & Update) {
@@ -3279,7 +3361,7 @@ export function reappearLayoutEffects(
3361 recursivelyTraverseReappearLayoutEffects(
3362 finishedRoot,
3363 finishedWork,
3282 - includeWorkInProgressEffects,
3364 + layoutEffectTraversalFlags,
3365 );
3366
3367 if (includeWorkInProgressEffects && flags & Update) {
@@ -3298,7 +3380,7 @@ export function reappearLayoutEffects(
3380 recursivelyTraverseReappearLayoutEffects(
3381 finishedRoot,
3382 finishedWork,
3301 - includeWorkInProgressEffects,
3383 + layoutEffectTraversalFlags,
3384 );
3385 }
3386 // TODO: Check flags & Ref
@@ -3310,7 +3392,7 @@ export function reappearLayoutEffects(
3392 recursivelyTraverseReappearLayoutEffects(
3393 finishedRoot,
3394 finishedWork,
3313 - includeWorkInProgressEffects,
3395 + layoutEffectTraversalFlags,
3396 );
3397 if (__DEV__) {
3398 if (flags & ViewTransitionNamedStatic) {
@@ -3332,7 +3414,7 @@ export function reappearLayoutEffects(
3414 recursivelyTraverseReappearLayoutEffects(
3415 finishedRoot,
3416 finishedWork,
3335 - includeWorkInProgressEffects,
3417 + layoutEffectTraversalFlags,
3418 );
3419 break;
3420 }
@@ -3365,14 +3447,15 @@ export function reappearLayoutEffects(
3447 function recursivelyTraverseReappearLayoutEffects(
3448 finishedRoot: FiberRoot,
3449 parentFiber: Fiber,
3368 - includeWorkInProgressEffects: boolean,
3450 + layoutEffectTraversalFlags: LayoutEffectTraversalFlags,
3451 ) {
3452 // This function visits both newly finished work and nodes that were re-used
3453 // from a previously committed tree. We cannot check non-static flags if the
3454 // node was reused.
3373 - const childShouldIncludeWorkInProgressEffects =
3374 - includeWorkInProgressEffects &&
3375 - (parentFiber.subtreeFlags & LayoutMask) !== NoFlags;
3455 + const childLayoutEffectTraversalFlags =
3456 + (parentFiber.subtreeFlags & LayoutMask) !== NoFlags
3457 + ? layoutEffectTraversalFlags
3458 + : layoutEffectTraversalFlags & ~IncludeWorkInProgressEffects;
3459
3460 // TODO (Offscreen) Check: flags & (RefStatic | LayoutStatic)
3461 let child = parentFiber.child;
@@ -3382,7 +3465,7 @@ function recursivelyTraverseReappearLayoutEffects(
3465 finishedRoot,
3466 current,
3467 child,
3385 - childShouldIncludeWorkInProgressEffects,
3468 + childLayoutEffectTraversalFlags,
3469 );
3470 child = child.sibling;
3471 }
packages/react-reconciler/src/ReactFiberWorkLoop.js
+4 -4
@@ -249,9 +249,9 @@ import {
249 commitMutationEffects,
250 commitPassiveMountEffects,
251 commitPassiveUnmountEffects,
252 - disappearLayoutEffects,
252 + disappearLayoutEffectsForDEVValidation,
253 reconnectPassiveEffects,
254 - reappearLayoutEffects,
254 + reappearLayoutEffectsForDEVValidation,
255 disconnectPassiveEffect,
256 invokeLayoutEffectMountInDEV,
257 invokePassiveEffectMountInDEV,
@@ -5314,9 +5314,9 @@ function recursivelyTraverseAndDoubleInvokeEffectsInDEV(
5314 function doubleInvokeEffectsOnFiber(root: FiberRoot, fiber: Fiber) {
5315 setIsStrictModeForDevtools(true);
5316 try {
5317 - disappearLayoutEffects(fiber);
5317 + disappearLayoutEffectsForDEVValidation(fiber);
5318 disconnectPassiveEffect(fiber);
5319 - reappearLayoutEffects(root, fiber.alternate, fiber, false);
5319 + reappearLayoutEffectsForDEVValidation(root, fiber.alternate, fiber);
5320 reconnectPassiveEffects(root, fiber, NoLanes, null, false, 0);
5321 } finally {
5322 setIsStrictModeForDevtools(false);