@samitouri / QOS-React-1 / commits / bfb7a76884

[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) {