[Fiber] Prevent metadata hoisting in hidden `<Activity>` trees (#34983)
Co-authored-by: Amp <amp@ampcode.com> Co-authored-by: Sebastian Sebbie Silbermann <sebastian.silbermann@vercel.com>
ronnakamoto committed
Aug 11, 2026 at 15:10 UTC
bfb7a76884b4ec54b9e29ddc7a0b7e4993d5ecea
4 files changed
+635
-17
packages/react-dom/src/__tests__/ReactDOMFloat-test.js
+402
@@ -9770,6 +9770,408 @@ background-color: green;
9770
<title data-foo="bar">another title</title>,
9771
);
9772
});
9773
+
9774
+ it('does not hoist title tags inside hidden Activity boundaries', async () => {
9775
+ const Activity = React.Activity;
9776
+ const root = ReactDOMClient.createRoot(container);
9777
+
9778
+ await act(() => {
9779
+ root.render(
9780
+ <div>
9781
+ <Activity mode="visible">
9782
+ <title>Visible Title</title>
9783
+ </Activity>
9784
+ <Activity mode="hidden">
9785
+ <title>Hidden Title</title>
9786
+ </Activity>
9787
+ </div>,
9788
+ );
9789
+ });
9790
+ await waitForAll([]);
9791
+
9792
+ // Only the visible Activity's title should be hoisted
9793
+ expect(getMeaningfulChildren(document.head)).toEqual(
9794
+ <title>Visible Title</title>,
9795
+ );
9796
+ });
9797
+
9798
+ it('removes title tags when Activity transitions from visible to hidden', async () => {
9799
+ const Activity = React.Activity;
9800
+ const root = ReactDOMClient.createRoot(container);
9801
+
9802
+ await act(() => {
9803
+ root.render(
9804
+ <div>
9805
+ <Activity mode="visible">
9806
+ <title>Activity Title</title>
9807
+ </Activity>
9808
+ </div>,
9809
+ );
9810
+ });
9811
+ await waitForAll([]);
9812
+
9813
+ // Title should be hoisted
9814
+ expect(getMeaningfulChildren(document.head)).toEqual(
9815
+ <title>Activity Title</title>,
9816
+ );
9817
+
9818
+ // Hide the Activity
9819
+ await act(() => {
9820
+ root.render(
9821
+ <div>
9822
+ <Activity mode="hidden">
9823
+ <title>Activity Title</title>
9824
+ </Activity>
9825
+ </div>,
9826
+ );
9827
+ });
9828
+ await waitForAll([]);
9829
+
9830
+ // Title should be removed from document head
9831
+ expect(getMeaningfulChildren(document.head)).toEqual(undefined);
9832
+ });
9833
+
9834
+ it('adds title tags when Activity transitions from hidden to visible', async () => {
9835
+ const Activity = React.Activity;
9836
+ const root = ReactDOMClient.createRoot(container);
9837
+
9838
+ await act(() => {
9839
+ root.render(
9840
+ <div>
9841
+ <Activity mode="hidden">
9842
+ <title>Activity Title</title>
9843
+ </Activity>
9844
+ </div>,
9845
+ );
9846
+ });
9847
+ await waitForAll([]);
9848
+
9849
+ // Title should not be hoisted
9850
+ expect(getMeaningfulChildren(document.head)).toEqual(undefined);
9851
+
9852
+ // Show the Activity
9853
+ await act(() => {
9854
+ root.render(
9855
+ <div>
9856
+ <Activity mode="visible">
9857
+ <title>Activity Title</title>
9858
+ </Activity>
9859
+ </div>,
9860
+ );
9861
+ });
9862
+ await waitForAll([]);
9863
+
9864
+ // Title should now be hoisted
9865
+ // The title retains an empty style attribute from being previously hidden
9866
+ expect(getMeaningfulChildren(document.head)).toEqual(
9867
+ <title style="">Activity Title</title>,
9868
+ );
9869
+ });
9870
+
9871
+ it('handles multiple Activity boundaries with different visibility states', async () => {
9872
+ const Activity = React.Activity;
9873
+ const root = ReactDOMClient.createRoot(container);
9874
+
9875
+ await act(() => {
9876
+ root.render(
9877
+ <div>
9878
+ <Activity mode="visible">
9879
+ <title>First Title</title>
9880
+ </Activity>
9881
+ <Activity mode="hidden">
9882
+ <title>Second Title</title>
9883
+ </Activity>
9884
+ <Activity mode="visible">
9885
+ <title>Third Title</title>
9886
+ </Activity>
9887
+ </div>,
9888
+ );
9889
+ });
9890
+ await waitForAll([]);
9891
+
9892
+ // Only visible Activities' titles should be hoisted
9893
+ // Both visible titles are hoisted, but the last one in tree order wins
9894
+ expect(getMeaningfulChildren(document.head)).toEqual([
9895
+ <title>Third Title</title>,
9896
+ <title>First Title</title>,
9897
+ ]);
9898
+ });
9899
+
9900
+ it('handles nested Activity boundaries correctly', async () => {
9901
+ const Activity = React.Activity;
9902
+ const root = ReactDOMClient.createRoot(container);
9903
+
9904
+ await act(() => {
9905
+ root.render(
9906
+ <div>
9907
+ <Activity mode="visible">
9908
+ <title>Outer Title</title>
9909
+ <Activity mode="hidden">
9910
+ <title>Inner Hidden Title</title>
9911
+ </Activity>
9912
+ </Activity>
9913
+ </div>,
9914
+ );
9915
+ });
9916
+ await waitForAll([]);
9917
+
9918
+ // Only the outer visible Activity's title should be hoisted
9919
+ // The inner hidden Activity's title should not be hoisted
9920
+ expect(getMeaningfulChildren(document.head)).toEqual(
9921
+ <title>Outer Title</title>,
9922
+ );
9923
+ });
9924
+
9925
+ it('handles meta tags inside hidden Activity boundaries', async () => {
9926
+ const Activity = React.Activity;
9927
+ const root = ReactDOMClient.createRoot(container);
9928
+
9929
+ await act(() => {
9930
+ root.render(
9931
+ <div>
9932
+ <Activity mode="visible">
9933
+ <meta name="visible" content="visible-content" />
9934
+ </Activity>
9935
+ <Activity mode="hidden">
9936
+ <meta name="hidden" content="hidden-content" />
9937
+ </Activity>
9938
+ </div>,
9939
+ );
9940
+ });
9941
+ await waitForAll([]);
9942
+
9943
+ // Only the visible Activity's meta should be hoisted
9944
+ expect(getMeaningfulChildren(document.head)).toEqual(
9945
+ <meta name="visible" content="visible-content" />,
9946
+ );
9947
+ });
9948
+
9949
+ it('does not hoist a hoistable nested under a HostComponent inside a hidden Activity', async () => {
9950
+ const Activity = React.Activity;
9951
+ const root = ReactDOMClient.createRoot(container);
9952
+
9953
+ await act(() => {
9954
+ root.render(
9955
+ <Activity mode="hidden">
9956
+ <div>
9957
+ <title>Nested Hidden Title</title>
9958
+ <meta name="nested-hidden" content="nope" />
9959
+ </div>
9960
+ </Activity>,
9961
+ );
9962
+ });
9963
+ await waitForAll([]);
9964
+
9965
+ expect(getMeaningfulChildren(document.head)).toEqual(undefined);
9966
+ });
9967
+
9968
+ it('mounts nested hoistables when their ancestor Activity transitions to visible', async () => {
9969
+ const Activity = React.Activity;
9970
+ const root = ReactDOMClient.createRoot(container);
9971
+
9972
+ await act(() => {
9973
+ root.render(
9974
+ <Activity mode="hidden">
9975
+ <div>
9976
+ <title>Reveal Me</title>
9977
+ </div>
9978
+ </Activity>,
9979
+ );
9980
+ });
9981
+ await waitForAll([]);
9982
+ expect(getMeaningfulChildren(document.head)).toEqual(undefined);
9983
+
9984
+ await act(() => {
9985
+ root.render(
9986
+ <Activity mode="visible">
9987
+ <div>
9988
+ <title>Reveal Me</title>
9989
+ </div>
9990
+ </Activity>,
9991
+ );
9992
+ });
9993
+ await waitForAll([]);
9994
+
9995
+ expect(getMeaningfulChildren(document.head)).toEqual(
9996
+ <title>Reveal Me</title>,
9997
+ );
9998
+ });
9999
+
10000
+ it('updates a hidden title without inserting it into the head', async () => {
10001
+ const Activity = React.Activity;
10002
+ const root = ReactDOMClient.createRoot(container);
10003
+
10004
+ await act(() => {
10005
+ root.render(
10006
+ <Activity mode="hidden">
10007
+ <title>Original Hidden</title>
10008
+ </Activity>,
10009
+ );
10010
+ });
10011
+ await waitForAll([]);
10012
+ expect(getMeaningfulChildren(document.head)).toEqual(undefined);
10013
+
10014
+ // Update the prop while still hidden — the head must remain empty.
10015
+ await act(() => {
10016
+ root.render(
10017
+ <Activity mode="hidden">
10018
+ <title>Updated Hidden</title>
10019
+ </Activity>,
10020
+ );
10021
+ });
10022
+ await waitForAll([]);
10023
+ expect(getMeaningfulChildren(document.head)).toEqual(undefined);
10024
+
10025
+ // Now reveal — the latest text should be in the head.
10026
+ await act(() => {
10027
+ root.render(
10028
+ <Activity mode="visible">
10029
+ <title>Updated Hidden</title>
10030
+ </Activity>,
10031
+ );
10032
+ });
10033
+ await waitForAll([]);
10034
+
10035
+ expect(getMeaningfulChildren(document.head)).toEqual(
10036
+ <title style="">Updated Hidden</title>,
10037
+ );
10038
+ });
10039
+
10040
+ it('removes a previously-mounted title when its Activity is deleted while hidden', async () => {
10041
+ const Activity = React.Activity;
10042
+ const root = ReactDOMClient.createRoot(container);
10043
+
10044
+ await act(() => {
10045
+ root.render(
10046
+ <Activity mode="visible">
10047
+ <title>To Be Deleted</title>
10048
+ </Activity>,
10049
+ );
10050
+ });
10051
+ await waitForAll([]);
10052
+ expect(getMeaningfulChildren(document.head)).toEqual(
10053
+ <title>To Be Deleted</title>,
10054
+ );
10055
+
10056
+ // Hide first — this unmounts from the head.
10057
+ await act(() => {
10058
+ root.render(
10059
+ <Activity mode="hidden">
10060
+ <title>To Be Deleted</title>
10061
+ </Activity>,
10062
+ );
10063
+ });
10064
+ await waitForAll([]);
10065
+ expect(getMeaningfulChildren(document.head)).toEqual(undefined);
10066
+
10067
+ // Delete the entire Activity while it's hidden. This must not throw
10068
+ // (the deletion path needs to tolerate already-detached instances).
10069
+ await act(() => {
10070
+ root.render(<div />);
10071
+ });
10072
+ await waitForAll([]);
10073
+ expect(getMeaningfulChildren(document.head)).toEqual(undefined);
10074
+ });
10075
+
10076
+ it('does not hoist hidden Activity metadata during hydration', async () => {
10077
+ const Activity = React.Activity;
10078
+
10079
+ // SSR a page where the only title belongs to a VISIBLE Activity. The
10080
+ // hidden Activity has its own <title> that must not end up in <head>.
10081
+ await act(() => {
10082
+ const {pipe} = renderToPipeableStream(
10083
+ <html>
10084
+ <body>
10085
+ <Activity mode="visible">
10086
+ <title>Visible Title</title>
10087
+ </Activity>
10088
+ <Activity mode="hidden">
10089
+ <title>Hidden Title</title>
10090
+ </Activity>
10091
+ </body>
10092
+ </html>,
10093
+ );
10094
+ pipe(writable);
10095
+ });
10096
+
10097
+ // After SSR, only the visible title should be in the head.
10098
+ expect(getMeaningfulChildren(document.head)).toEqual(
10099
+ <title>Visible Title</title>,
10100
+ );
10101
+
10102
+ // Hydrate. The hidden Activity's <title> must not be inserted into the
10103
+ // head as a side effect of hydration.
10104
+ ReactDOMClient.hydrateRoot(
10105
+ document,
10106
+ <html>
10107
+ <body>
10108
+ <Activity mode="visible">
10109
+ <title>Visible Title</title>
10110
+ </Activity>
10111
+ <Activity mode="hidden">
10112
+ <title>Hidden Title</title>
10113
+ </Activity>
10114
+ </body>
10115
+ </html>,
10116
+ );
10117
+ await waitForAll([]);
10118
+
10119
+ expect(getMeaningfulChildren(document.head)).toEqual(
10120
+ <title>Visible Title</title>,
10121
+ );
10122
+ });
10123
+
10124
+ it('handles StrictMode without leaving duplicate or missing hoistables', async () => {
10125
+ const Activity = React.Activity;
10126
+ const root = ReactDOMClient.createRoot(container);
10127
+
10128
+ // StrictMode triggers a dev-only double invoke of layout effects, which
10129
+ // exercises our disappear/reappear hoistable handling. The final state
10130
+ // must be a single visible title and no hidden title.
10131
+ await act(() => {
10132
+ root.render(
10133
+ <React.StrictMode>
10134
+ <div>
10135
+ <Activity mode="visible">
10136
+ <title>StrictMode Visible</title>
10137
+ </Activity>
10138
+ <Activity mode="hidden">
10139
+ <title>StrictMode Hidden</title>
10140
+ </Activity>
10141
+ </div>
10142
+ </React.StrictMode>,
10143
+ );
10144
+ });
10145
+ await waitForAll([]);
10146
+
10147
+ expect(getMeaningfulChildren(document.head)).toEqual(
10148
+ <title>StrictMode Visible</title>,
10149
+ );
10150
+
10151
+ // Toggle visibility — double invoke must still leave a clean head
10152
+ // with exactly one title (no duplicates, no missing).
10153
+ await act(() => {
10154
+ root.render(
10155
+ <React.StrictMode>
10156
+ <div>
10157
+ <Activity mode="hidden">
10158
+ <title>StrictMode Visible</title>
10159
+ </Activity>
10160
+ <Activity mode="visible">
10161
+ <title>StrictMode Hidden</title>
10162
+ </Activity>
10163
+ </div>
10164
+ </React.StrictMode>,
10165
+ );
10166
+ });
10167
+ await waitForAll([]);
10168
+
10169
+ // The previously-hidden title that just became visible carries a
10170
+ // style="" attribute from hideInstance/unhideInstance.
10171
+ expect(getMeaningfulChildren(document.head)).toEqual(
10172
+ <title style="">StrictMode Hidden</title>,
10173
+ );
10174
+ });
10175
});
10176
10177
it('does not outline a boundary with suspensey CSS when flushing the shell', async () => {
packages/react-dom/src/__tests__/ReactRenderDocument-test.js
+87
@@ -248,6 +248,93 @@ describe('rendering React components at document', () => {
248
expect(container.textContent).toBe('parsnip');
249
});
250
251
+ it('removes hoisted <title> when hiding an Activity boundary', async () => {
252
+ const Activity = React.Activity;
253
+
254
+ function App({mode, titleText}) {
255
+ return (
256
+ <html>
257
+ <head>
258
+ <Activity mode={mode}>
259
+ <title>{titleText}</title>
260
+ </Activity>
261
+ </head>
262
+ <body>Hello</body>
263
+ </html>
264
+ );
265
+ }
266
+
267
+ const testDocument = getTestDocument(
268
+ '<!doctype html><html><head></head><body></body></html>',
269
+ );
270
+ const root = ReactDOMClient.createRoot(testDocument);
271
+
272
+ await act(() => root.render(<App mode="visible" titleText="A" />));
273
+ expect(testDocument.head.querySelector('title').textContent).toBe('A');
274
+
275
+ await act(() => root.render(<App mode="hidden" titleText="A" />));
276
+ expect(testDocument.head.querySelector('title')).toBe(null);
277
+
278
+ await act(() => root.render(<App mode="visible" titleText="B" />));
279
+ expect(testDocument.head.querySelector('title').textContent).toBe('B');
280
+ });
281
+
282
+ it('does not unmount a hoistable that was never mounted when reappearing', async () => {
283
+ const Activity = React.Activity;
284
+
285
+ function App({mode, showTitle}) {
286
+ return (
287
+ <html>
288
+ <head>
289
+ <Activity mode={mode}>
290
+ {showTitle ? <title>Title</title> : null}
291
+ </Activity>
292
+ </head>
293
+ <body>Hello</body>
294
+ </html>
295
+ );
296
+ }
297
+
298
+ const testDocument = getTestDocument(
299
+ '<!doctype html><html><head></head><body></body></html>',
300
+ );
301
+ const root = ReactDOMClient.createRoot(testDocument);
302
+
303
+ await act(() => root.render(<App mode="hidden" showTitle={true} />));
304
+ expect(testDocument.head.querySelector('title')).toBe(null);
305
+
306
+ await act(() => root.render(<App mode="visible" showTitle={false} />));
307
+ expect(testDocument.head.querySelector('title')).toBe(null);
308
+ });
309
+
310
+ it('removes hoistables deleted in the same commit that hides an Activity', async () => {
311
+ const Activity = React.Activity;
312
+
313
+ function App({mode, showTitle}) {
314
+ return (
315
+ <html>
316
+ <head>
317
+ <Activity mode={mode}>
318
+ {showTitle ? <title>Title</title> : null}
319
+ </Activity>
320
+ </head>
321
+ <body>Hello</body>
322
+ </html>
323
+ );
324
+ }
325
+
326
+ const testDocument = getTestDocument(
327
+ '<!doctype html><html><head></head><body></body></html>',
328
+ );
329
+ const root = ReactDOMClient.createRoot(testDocument);
330
+
331
+ await act(() => root.render(<App mode="visible" showTitle={true} />));
332
+ expect(testDocument.head.querySelector('title')).not.toBe(null);
333
+
334
+ await act(() => root.render(<App mode="hidden" showTitle={false} />));
335
+ expect(testDocument.head.querySelector('title')).toBe(null);
336
+ });
337
+
338
it('should give helpful errors on state desync', async () => {
339
class Component extends React.Component {
340
render() {
packages/react-reconciler/src/ReactFiberBeginWork.js
+7
@@ -1168,6 +1168,13 @@ function updateActivityComponent(
1168
renderLanes,
1169
);
1170
workInProgress.lanes = laneToLanes(OffscreenLane);
1171
+ // This tree hasn't been mounted yet so there are no baseLanes to carry over.
1172
+ const nextState: OffscreenState = {
1173
+ baseLanes: NoLanes,
1174
+ cachePool: null,
1175
+ };
1176
+ primaryChildFragment.memoizedState = nextState;
1177
+
1178
return bailoutOffscreenComponent(null, primaryChildFragment);
1179
} else {
1180
// We must push the suspense handler context *before* attempting to
packages/react-reconciler/src/ReactFiberCommitWork.js
+139
-17
@@ -169,6 +169,7 @@ import {
169
acquireResource,
170
releaseResource,
171
hydrateHoistable,
172
+ createHoistableInstance,
173
mountHoistable,
174
unmountHoistable,
175
prepareToCommitHoistables,
@@ -1515,7 +1516,13 @@ function commitDeletionEffectsOnFiber(
1516
if (deletedFiber.memoizedState) {
1517
releaseResource(deletedFiber.memoizedState);
1518
} else if (deletedFiber.stateNode) {
1518
- unmountHoistable(deletedFiber.stateNode);
1519
+ // A Hoistable Instance lives in document.head only when its enclosing
1520
+ // Activity is visible. If the Activity is hidden (or has been hidden
1521
+ // since mount), the instance was either never inserted or was
1522
+ // detached by the disappear traversal. Skip in those cases.
1523
+ if (!offscreenSubtreeWasHidden) {
1524
+ unmountHoistable(deletedFiber.stateNode);
1525
+ }
1526
}
1527
break;
1528
}
@@ -2157,13 +2164,32 @@ function commitMutationEffectsOnFiber(
2164
// or a Hoistable Resource
2165
if (newResource === null) {
2166
if (finishedWork.stateNode === null) {
2160
- finishedWork.stateNode = hydrateHoistable(
2161
- hoistableRoot,
2162
- finishedWork.type,
2163
- finishedWork.memoizedProps,
2164
- finishedWork,
2165
- );
2166
- } else {
2167
+ // Initial mount. The instance has not been created yet, which
2168
+ // happens during hydration (createHoistableInstance is normally
2169
+ // called in beginWork's updateHostHoistable, but is skipped
2170
+ // when hydrating).
2171
+ if (offscreenSubtreeIsHidden) {
2172
+ // We're inside a hidden Activity boundary. Create the
2173
+ // instance off-document so we don't leak metadata into
2174
+ // the head. It will be mounted by the reappear path when
2175
+ // the Activity becomes visible.
2176
+ finishedWork.stateNode = createHoistableInstance(
2177
+ finishedWork.type,
2178
+ finishedWork.memoizedProps,
2179
+ root.containerInfo,
2180
+ finishedWork,
2181
+ );
2182
+ } else {
2183
+ finishedWork.stateNode = hydrateHoistable(
2184
+ hoistableRoot,
2185
+ finishedWork.type,
2186
+ finishedWork.memoizedProps,
2187
+ finishedWork,
2188
+ );
2189
+ }
2190
+ } else if (!offscreenSubtreeIsHidden) {
2191
+ // The instance was created in beginWork. Only mount it into
2192
+ // the document if we're not inside a hidden Activity boundary.
2193
mountHoistable(
2194
hoistableRoot,
2195
finishedWork.type,
@@ -2180,18 +2206,27 @@ function commitMutationEffectsOnFiber(
2206
} else if (currentResource !== newResource) {
2207
// We are moving to or from Hoistable Resource, or between different Hoistable Resources
2208
if (currentResource === null) {
2183
- if (current.stateNode !== null) {
2184
- unmountHoistable(current.stateNode);
2209
+ // Transitioning from Instance to Resource. Only unmount when the
2210
+ // Instance is currently mounted in the document; hidden Activity
2211
+ // boundaries keep instances off-document or detach them before
2212
+ // this update is processed.
2213
+ const instance = current.stateNode;
2214
+ if (instance !== null && !offscreenSubtreeWasHidden) {
2215
+ unmountHoistable(instance);
2216
}
2217
} else {
2218
releaseResource(currentResource);
2219
}
2220
if (newResource === null) {
2190
- mountHoistable(
2191
- hoistableRoot,
2192
- finishedWork.type,
2193
- finishedWork.stateNode,
2194
- );
2221
+ // Transitioning to an Instance. Only mount if visible; hidden
2222
+ // Activity boundaries will mount via the reappear path.
2223
+ if (!offscreenSubtreeIsHidden) {
2224
+ mountHoistable(
2225
+ hoistableRoot,
2226
+ finishedWork.type,
2227
+ finishedWork.stateNode,
2228
+ );
2229
+ }
2230
} else {
2231
acquireResource(
2232
hoistableRoot,
@@ -2605,6 +2640,16 @@ function commitMutationEffectsOnFiber(
2640
} else {
2641
layoutEffectTraversalFlags = NoLayoutEffectTraversalFlags;
2642
}
2643
+ const newOffscreenSubtreeIsHidden =
2644
+ // $FlowFixMe[constant-condition]
2645
+ isHidden || offscreenSubtreeIsHidden;
2646
+ const newOffscreenSubtreeWasHidden =
2647
+ // $FlowFixMe[constant-condition]
2648
+ wasHidden || offscreenSubtreeWasHidden;
2649
+ const prevOffscreenSubtreeIsHidden = offscreenSubtreeIsHidden;
2650
+ const prevOffscreenSubtreeWasHidden = offscreenSubtreeWasHidden;
2651
+ offscreenSubtreeIsHidden = newOffscreenSubtreeIsHidden;
2652
+ offscreenSubtreeWasHidden = newOffscreenSubtreeWasHidden;
2653
recursivelyTraverseDisappearLayoutEffects(
2654
finishedWork,
2655
layoutEffectTraversalFlags,
@@ -2625,6 +2670,8 @@ function commitMutationEffectsOnFiber(
2670
componentEffectEndTime,
2671
);
2672
}
2673
+ offscreenSubtreeIsHidden = prevOffscreenSubtreeIsHidden;
2674
+ offscreenSubtreeWasHidden = prevOffscreenSubtreeWasHidden;
2675
}
2676
}
2677
}
@@ -3097,7 +3144,6 @@ function disappearLayoutEffects(
3144
}
3145
// Expected fallthrough to HostComponent
3146
}
3100
- case HostHoistable:
3147
case HostComponent: {
3148
// TODO (Offscreen) Check: flags & RefStatic
3149
safelyDetachRef(finishedWork, finishedWork.return);
@@ -3117,6 +3163,32 @@ function disappearLayoutEffects(
3163
);
3164
break;
3165
}
3166
+ case HostHoistable: {
3167
+ // TODO (Offscreen) Check: flags & RefStatic
3168
+ safelyDetachRef(finishedWork, finishedWork.return);
3169
+
3170
+ // $FlowFixMe[constant-condition]
3171
+ if (supportsResources) {
3172
+ // We only act on Hoistable Instances (memoizedState === null).
3173
+ // Resources (memoizedState !== null) are ref-counted and intentionally
3174
+ // remain in the document across Activity visibility transitions;
3175
+ // they are released only on actual deletion.
3176
+ const instance = finishedWork.stateNode;
3177
+ if (
3178
+ finishedWork.memoizedState === null &&
3179
+ instance !== null &&
3180
+ !offscreenSubtreeWasHidden
3181
+ ) {
3182
+ unmountHoistable(instance);
3183
+ }
3184
+ }
3185
+
3186
+ recursivelyTraverseDisappearLayoutEffects(
3187
+ finishedWork,
3188
+ layoutEffectTraversalFlags,
3189
+ );
3190
+ break;
3191
+ }
3192
case OffscreenComponent: {
3193
const isHidden = finishedWork.memoizedState !== null;
3194
if (isHidden) {
@@ -3288,7 +3360,6 @@ function reappearLayoutEffects(
3360
}
3361
// Fallthrough
3362
}
3291
- case HostHoistable:
3363
case HostComponent: {
3364
// TODO: Enable HostText for RN
3365
if (
@@ -3316,6 +3387,57 @@ function reappearLayoutEffects(
3387
safelyAttachRef(finishedWork, finishedWork.return);
3388
break;
3389
}
3390
+ case HostHoistable: {
3391
+ // $FlowFixMe[constant-condition]
3392
+ if (supportsResources) {
3393
+ // The reappear traversal runs whenever an Activity transitions from
3394
+ // hidden to visible. We piggy-back on it (rather than adding a
3395
+ // separate recursive traversal) to insert hoistable metadata such as
3396
+ // <title> and <meta> into the document.
3397
+ //
3398
+ // We only act on Hoistable Instances (memoizedState === null).
3399
+ // Resources stay mounted across Activity visibility transitions.
3400
+ //
3401
+ // The parentNode guard makes this idempotent and safe under StrictMode
3402
+ // dev double-invoke: if the instance is already attached we skip.
3403
+ //
3404
+ // Note: this runs in the layout phase. A useLayoutEffect on an earlier
3405
+ // sibling can therefore observe document.title before the hoistable
3406
+ // is re-attached. Moving this to the mutation phase would require an
3407
+ // additional unconditional traversal of the Activity subtree (the
3408
+ // mutation traversal is gated by subtreeFlags and would skip an
3409
+ // unchanged hoistable). This is the same tradeoff as for HostSingleton.
3410
+ const instance = finishedWork.stateNode;
3411
+ if (
3412
+ finishedWork.memoizedState === null &&
3413
+ instance !== null &&
3414
+ !offscreenSubtreeIsHidden
3415
+ ) {
3416
+ // currentHoistableRoot is only maintained during the mutation phase.
3417
+ // Derive the hoistable root from the instance's owner document so
3418
+ // this works in the layout phase too. Hoistable Instances are
3419
+ // hoisted to document.head, which always lives in ownerDocument.
3420
+ mountHoistable(
3421
+ getHoistableRoot(instance.ownerDocument),
3422
+ finishedWork.type,
3423
+ instance,
3424
+ );
3425
+ }
3426
+ }
3427
+ recursivelyTraverseReappearLayoutEffects(
3428
+ finishedRoot,
3429
+ finishedWork,
3430
+ layoutEffectTraversalFlags,
3431
+ );
3432
+
3433
+ if (includeWorkInProgressEffects && current === null && flags & Update) {
3434
+ commitHostMount(finishedWork);
3435
+ }
3436
+
3437
+ // TODO: Check flags & Ref
3438
+ safelyAttachRef(finishedWork, finishedWork.return);
3439
+ break;
3440
+ }
3441
case Profiler: {
3442
// TODO: Figure out how Profiler updates should work with Offscreen
3443
if (includeWorkInProgressEffects && flags & Update) {