Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
183 changes: 183 additions & 0 deletions packages/react-dom/src/__tests__/ReactDOMFloat-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -9424,5 +9424,188 @@ background-color: green;
<title data-foo="bar">another title</title>,
);
});

// @gate enableActivity
it('does not hoist title tags inside hidden Activity boundaries', async () => {
const Activity = React.Activity;
const root = ReactDOMClient.createRoot(container);

await act(() => {
root.render(
<div>
<Activity mode="visible">
<title>Visible Title</title>
</Activity>
<Activity mode="hidden">
<title>Hidden Title</title>
</Activity>
</div>,
);
});
await waitForAll([]);

// Only the visible Activity's title should be hoisted
expect(getMeaningfulChildren(document.head)).toEqual(
<title>Visible Title</title>,
);
});

// @gate enableActivity
it('removes title tags when Activity transitions from visible to hidden', async () => {
const Activity = React.Activity;
const root = ReactDOMClient.createRoot(container);

await act(() => {
root.render(
<div>
<Activity mode="visible">
<title>Activity Title</title>
</Activity>
</div>,
);
});
await waitForAll([]);

// Title should be hoisted
expect(getMeaningfulChildren(document.head)).toEqual(
<title>Activity Title</title>,
);

// Hide the Activity
await act(() => {
root.render(
<div>
<Activity mode="hidden">
<title>Activity Title</title>
</Activity>
</div>,
);
});
await waitForAll([]);

// Title should be removed from document head
expect(getMeaningfulChildren(document.head)).toEqual(undefined);
});

// @gate enableActivity
it('adds title tags when Activity transitions from hidden to visible', async () => {
const Activity = React.Activity;
const root = ReactDOMClient.createRoot(container);

await act(() => {
root.render(
<div>
<Activity mode="hidden">
<title>Activity Title</title>
</Activity>
</div>,
);
});
await waitForAll([]);

// Title should not be hoisted
expect(getMeaningfulChildren(document.head)).toEqual(undefined);

// Show the Activity
await act(() => {
root.render(
<div>
<Activity mode="visible">
<title>Activity Title</title>
</Activity>
</div>,
);
});
await waitForAll([]);

// Title should now be hoisted
// Note: The title may have a style attribute from being previously hidden
const titleElement = getMeaningfulChildren(document.head);
expect(titleElement).toBeTruthy();
expect(titleElement.type).toBe('title');
expect(titleElement.props.children).toBe('Activity Title');
});

// @gate enableActivity
it('handles multiple Activity boundaries with different visibility states', async () => {
const Activity = React.Activity;
const root = ReactDOMClient.createRoot(container);

await act(() => {
root.render(
<div>
<Activity mode="visible">
<title>First Title</title>
</Activity>
<Activity mode="hidden">
<title>Second Title</title>
</Activity>
<Activity mode="visible">
<title>Third Title</title>
</Activity>
</div>,
);
});
await waitForAll([]);

// Only visible Activities' titles should be hoisted
// Both visible titles are hoisted, but the last one in tree order wins
const titles = getMeaningfulChildren(document.head);
expect(Array.isArray(titles)).toBe(true);
expect(titles.length).toBe(2);
expect(titles[0].props.children).toBe('Third Title');
expect(titles[1].props.children).toBe('First Title');
});

// @gate enableActivity
it('handles nested Activity boundaries correctly', async () => {
const Activity = React.Activity;
const root = ReactDOMClient.createRoot(container);

await act(() => {
root.render(
<div>
<Activity mode="visible">
<title>Outer Title</title>
<Activity mode="hidden">
<title>Inner Hidden Title</title>
</Activity>
</Activity>
</div>,
);
});
await waitForAll([]);

// Only the outer visible Activity's title should be hoisted
// The inner hidden Activity's title should not be hoisted
expect(getMeaningfulChildren(document.head)).toEqual(
<title>Outer Title</title>,
);
});

// @gate enableActivity
it('handles meta tags inside hidden Activity boundaries', async () => {
const Activity = React.Activity;
const root = ReactDOMClient.createRoot(container);

await act(() => {
root.render(
<div>
<Activity mode="visible">
<meta name="visible" content="visible-content" />
</Activity>
<Activity mode="hidden">
<meta name="hidden" content="hidden-content" />
</Activity>
</div>,
);
});
await waitForAll([]);

// Only the visible Activity's meta should be hoisted
expect(getMeaningfulChildren(document.head)).toEqual(
<meta name="visible" content="visible-content" />,
);
});
});
});
127 changes: 116 additions & 11 deletions packages/react-reconciler/src/ReactFiberCommitWork.js
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ import {
ForceClientRender,
DidCapture,
AffectedParentLayout,
HoistableStatic,
ViewTransitionNamedStatic,
} from './ReactFiberFlags';
import {
Expand Down Expand Up @@ -1412,6 +1413,71 @@ function commitDeletionEffects(
detachFiberMutation(deletedFiber);
}

// Helper function to recursively mount hoistables when an Activity becomes visible
function recursivelyMountHoistablesInSubtree(
hoistableRoot: HoistableRoot,
fiber: Fiber,
): void {
if (supportsResources) {
// Traverse the fiber tree and mount any hoistables
let child = fiber.child;
while (child !== null) {
if (child.tag === HostHoistable) {
// This is a hoistable element
if (child.memoizedState === null && child.stateNode !== null) {
// This is a Hoistable Instance (not a Resource)
// Mount it to the document head
mountHoistable(hoistableRoot, child.type, child.stateNode);
}
} else if (
child.tag !== OffscreenComponent &&
child.tag !== ActivityComponent
) {
// Don't traverse into nested Offscreen/Activity boundaries
// They manage their own hoistables

// Check if this subtree contains any hoistables before traversing.
// The HoistableStatic flag is set during the complete phase and bubbled up
// through subtreeFlags, allowing us to skip entire subtrees that don't
// contain any hoistables.
if ((child.subtreeFlags & HoistableStatic) !== NoFlags) {
recursivelyMountHoistablesInSubtree(hoistableRoot, child);
}
}
child = child.sibling;
}
}
}

// Helper function to recursively unmount hoistables when an Activity becomes hidden
function recursivelyUnmountHoistablesInSubtree(fiber: Fiber): void {
if (supportsResources) {
// Traverse the fiber tree and unmount any hoistables
let child = fiber.child;
while (child !== null) {
if (child.tag === HostHoistable) {
// This is a hoistable element
if (child.memoizedState === null && child.stateNode !== null) {
// This is a Hoistable Instance (not a Resource)
// Only unmount if it was actually mounted to the document
const instance = child.stateNode;
if (instance.parentNode) {
unmountHoistable(instance);
}
}
} else if (
child.tag !== OffscreenComponent &&
child.tag !== ActivityComponent
) {
if ((child.subtreeFlags & HoistableStatic) !== NoFlags) {
recursivelyUnmountHoistablesInSubtree(child);
}
}
child = child.sibling;
}
}
}

function recursivelyTraverseDeletionEffects(
finishedRoot: FiberRoot,
nearestMountedAncestor: Fiber,
Expand Down Expand Up @@ -1455,7 +1521,12 @@ function commitDeletionEffectsOnFiber(
if (deletedFiber.memoizedState) {
releaseResource(deletedFiber.memoizedState);
} else if (deletedFiber.stateNode) {
unmountHoistable(deletedFiber.stateNode);
// Only unmount if the hoistable was actually mounted to the document
// Hoistables in hidden Activity boundaries are never mounted
const instance = deletedFiber.stateNode;
if (instance.parentNode) {
unmountHoistable(instance);
}
}
break;
}
Expand Down Expand Up @@ -2076,11 +2147,15 @@ function commitMutationEffectsOnFiber(
finishedWork,
);
} else {
mountHoistable(
hoistableRoot,
finishedWork.type,
finishedWork.stateNode,
);
// Only mount the hoistable if we're not in a hidden offscreen subtree
// Hidden Activity boundaries should not hoist their metadata to the document
if (!offscreenSubtreeIsHidden) {
mountHoistable(
hoistableRoot,
finishedWork.type,
finishedWork.stateNode,
);
}
}
} else {
finishedWork.stateNode = acquireResource(
Expand All @@ -2099,11 +2174,14 @@ function commitMutationEffectsOnFiber(
releaseResource(currentResource);
}
if (newResource === null) {
mountHoistable(
hoistableRoot,
finishedWork.type,
finishedWork.stateNode,
);
// Only mount the hoistable if we're not in a hidden offscreen subtree
if (!offscreenSubtreeIsHidden) {
mountHoistable(
hoistableRoot,
finishedWork.type,
finishedWork.stateNode,
);
}
} else {
acquireResource(
hoistableRoot,
Expand Down Expand Up @@ -2511,6 +2589,33 @@ function commitMutationEffectsOnFiber(
);
}
}

// Unmount hoistables when transitioning from visible to hidden
// This ensures that metadata like <title> is removed from the document
// when an Activity becomes hidden
if (supportsResources) {
recursivelyUnmountHoistablesInSubtree(finishedWork);
}
}
} else {
// Becoming visible
// Only mount hoistables if:
// - This is an update (not first mount, which is handled elsewhere)
// - This Offscreen was hidden before
// - No ancestor Offscreen is hidden
if (
isUpdate &&
wasHidden &&
!offscreenSubtreeIsHidden &&
!offscreenSubtreeWasHidden
) {
// Mount hoistables when transitioning from hidden to visible
// This ensures that metadata like <title> is added to the document
// when an Activity becomes visible
if (supportsResources) {
const hoistableRoot: HoistableRoot = (currentHoistableRoot: any);
recursivelyMountHoistablesInSubtree(hoistableRoot, finishedWork);
}
}
}

Expand Down
7 changes: 7 additions & 0 deletions packages/react-reconciler/src/ReactFiberCompleteWork.js
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ import {
Cloned,
ViewTransitionStatic,
Hydrate,
HoistableStatic,
} from './ReactFiberFlags';

import {
Expand Down Expand Up @@ -1177,6 +1178,10 @@ function completeWork(
// @TODO refactor this block to create the instance here in complete
// phase if we are not hydrating.
markUpdate(workInProgress);
// Mark this fiber as containing a hoistable. This flag will bubble up
// through subtreeFlags, allowing us to skip traversals in subtrees that
// don't contain any hoistables during Activity visibility transitions.
workInProgress.flags |= HoistableStatic;
if (nextResource !== null) {
// This is a Hoistable Resource

Expand Down Expand Up @@ -1205,6 +1210,8 @@ function completeWork(
}
} else {
// This is an update.
// Mark this fiber as containing a hoistable.
workInProgress.flags |= HoistableStatic;
if (nextResource) {
// This is a Resource
if (nextResource !== current.memoizedState) {
Expand Down
Loading