Skip to content

Commit b32003c

Browse files
feat: portal support for react 19 (#34916)
Co-authored-by: Oleksandr Fediashov <olfedias@microsoft.com>
1 parent 609e0f3 commit b32003c

11 files changed

Lines changed: 416 additions & 84 deletions

File tree

apps/react-18-tests-v9/src/components/Portal/Portal.cy.tsx

Lines changed: 74 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,11 @@ const mount = (element: JSXElement) => {
99
mountBase(<Provider>{element}</Provider>);
1010
};
1111

12-
const TestComponent: React.FC = () => {
12+
const TestComponent: React.FC<{ focusTargetId?: string; triggerId?: string; children?: React.ReactNode }> = ({
13+
children,
14+
focusTargetId = 'focusTarget',
15+
triggerId = 'trigger',
16+
}) => {
1317
const ref = React.useRef<HTMLButtonElement>(null);
1418
const [open, setOpen] = React.useState(false);
1519
React.useEffect(() => {
@@ -20,13 +24,14 @@ const TestComponent: React.FC = () => {
2024

2125
return (
2226
<>
23-
<button id="trigger" onClick={() => setOpen(!open)}>
27+
<button id={triggerId} onClick={() => setOpen(!open)}>
2428
Toggle Portal
2529
</button>
2630
{open && (
2731
<Portal>
2832
<div style={{ position: 'fixed', top: 200, left: 100, padding: 20, background: '#ccc' }}>
29-
<button id="focusTarget" ref={ref}>
33+
{children}
34+
<button id={focusTargetId} ref={ref}>
3035
Test Button
3136
</button>
3237
</div>
@@ -65,4 +70,70 @@ describe('Portal', () => {
6570
cy.get('body').children().should('have.length', 1);
6671
cy.get('body').children().first().should('have.attr', 'data-cy-root');
6772
});
73+
74+
it('empty portal renders as expected', () => {
75+
mount(<Portal />);
76+
77+
cy.get('body > [data-portal-node="true"]').should('exist');
78+
// Check that the portal is empty (has no children except the <span hidden /> element)
79+
cy.get('body > [data-portal-node="true"]')
80+
.children()
81+
.should('have.length', 1)
82+
.first()
83+
.should('match', 'span[hidden]');
84+
});
85+
86+
it('remounting the portal should not cause issues', () => {
87+
mount(<TestComponent />);
88+
89+
// Open the portal
90+
cy.get('#trigger').realClick();
91+
cy.get('body > [data-portal-node="true"]').should('exist');
92+
93+
// Close the portal
94+
cy.get('#trigger').realClick();
95+
cy.get('body > [data-portal-node="true"]').should('not.exist');
96+
97+
// Open the portal again
98+
cy.get('#trigger').realClick();
99+
cy.get('body > [data-portal-node="true"]').should('exist');
100+
});
101+
102+
it('should handle multiple portals independently', () => {
103+
mount(
104+
<>
105+
<TestComponent triggerId="triggerA" focusTargetId="focusTargetA">
106+
<div id="portalA">Portal A Content</div>
107+
</TestComponent>
108+
<TestComponent triggerId="triggerB" focusTargetId="focusTargetB">
109+
<div id="portalB">Portal B Content</div>
110+
</TestComponent>
111+
</>,
112+
);
113+
114+
// Initially no portals
115+
cy.get('body > [data-portal-node="true"]').should('not.exist');
116+
117+
// Open Portal A
118+
cy.get('#triggerA').realClick();
119+
cy.get('body > [data-portal-node="true"]').should('have.length', 1);
120+
cy.get('#portalA').should('exist').and('contain.text', 'Portal A Content');
121+
122+
// Open Portal B
123+
cy.get('#triggerB').realClick();
124+
cy.get('body > [data-portal-node="true"]').should('have.length', 2);
125+
cy.get('#portalA').should('exist');
126+
cy.get('#portalB').should('exist').and('contain.text', 'Portal B Content');
127+
128+
// Close Portal A, Portal B should remain
129+
cy.get('#triggerA').realClick();
130+
cy.get('body > [data-portal-node="true"]').should('have.length', 1);
131+
cy.get('#portalA').should('not.exist');
132+
cy.get('#portalB').should('exist');
133+
134+
// Close Portal B
135+
cy.get('#triggerB').realClick();
136+
cy.get('body > [data-portal-node="true"]').should('not.exist');
137+
cy.get('#portalB').should('not.exist');
138+
});
68139
});
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"type": "minor",
3+
"comment": "feat: support for react 19",
4+
"packageName": "@fluentui/react-portal",
5+
"email": "dmytrokirpa@microsoft.com",
6+
"dependentChangeType": "patch"
7+
}

packages/charts/react-charts/library/src/components/GaugeChart/__snapshots__/GaugeChart.test.tsx.snap

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2887,6 +2887,9 @@ Object {
28872887
/>
28882888
25%
28892889
</div>
2890+
<span
2891+
hidden=""
2892+
/>
28902893
</div>
28912894
</body>,
28922895
"container": <div>
@@ -3242,6 +3245,9 @@ Object {
32423245
/>
32433246
25%
32443247
</div>
3248+
<span
3249+
hidden=""
3250+
/>
32453251
</div>
32463252
</body>,
32473253
"container": <div>
@@ -3617,6 +3623,9 @@ Object {
36173623
/>
36183624
25%
36193625
</div>
3626+
<span
3627+
hidden=""
3628+
/>
36203629
</div>
36213630
</body>,
36223631
"container": <div>
@@ -4037,6 +4046,9 @@ Object {
40374046
/>
40384047
60%
40394048
</div>
4049+
<span
4050+
hidden=""
4051+
/>
40404052
</div>
40414053
</body>,
40424054
"container": <div>
@@ -4465,6 +4477,9 @@ Object {
44654477
/>
44664478
25%
44674479
</div>
4480+
<span
4481+
hidden=""
4482+
/>
44684483
</div>
44694484
</body>,
44704485
"container": <div>
@@ -4923,6 +4938,9 @@ Object {
49234938
/>
49244939
25/100
49254940
</div>
4941+
<span
4942+
hidden=""
4943+
/>
49264944
</div>
49274945
</body>,
49284946
"container": <div>
@@ -5386,6 +5404,9 @@ Object {
53865404
/>
53875405
25%
53885406
</div>
5407+
<span
5408+
hidden=""
5409+
/>
53895410
</div>
53905411
<div
53915412
class=""
@@ -5402,6 +5423,9 @@ Object {
54025423
/>
54035424
Low Risk
54045425
</div>
5426+
<span
5427+
hidden=""
5428+
/>
54055429
</div>
54065430
</body>,
54075431
"container": <div>

packages/charts/react-charts/library/src/components/HorizontalBarChart/__snapshots__/HorizontalBarChart.test.tsx.snap

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1485,6 +1485,9 @@ Object {
14851485
>
14861486
one
14871487
</div>
1488+
<span
1489+
hidden=""
1490+
/>
14881491
</div>
14891492
<div
14901493
class=""
@@ -1498,6 +1501,9 @@ Object {
14981501
>
14991502
two
15001503
</div>
1504+
<span
1505+
hidden=""
1506+
/>
15011507
</div>
15021508
<div
15031509
class=""
@@ -1511,6 +1517,9 @@ Object {
15111517
>
15121518
three
15131519
</div>
1520+
<span
1521+
hidden=""
1522+
/>
15141523
</div>
15151524
</body>,
15161525
"container": <div>
@@ -1908,6 +1917,9 @@ Object {
19081917
>
19091918
one
19101919
</div>
1920+
<span
1921+
hidden=""
1922+
/>
19111923
</div>
19121924
<div
19131925
class=""
@@ -1921,6 +1933,9 @@ Object {
19211933
>
19221934
two
19231935
</div>
1936+
<span
1937+
hidden=""
1938+
/>
19241939
</div>
19251940
<div
19261941
class=""
@@ -1934,6 +1949,9 @@ Object {
19341949
>
19351950
three
19361951
</div>
1952+
<span
1953+
hidden=""
1954+
/>
19371955
</div>
19381956
</body>,
19391957
"container": <div>

packages/react-components/react-portal/library/package.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,7 @@
2222
"@fluentui/react-tabster": "^9.26.2",
2323
"@fluentui/react-utilities": "^9.23.1",
2424
"@griffel/react": "^1.5.22",
25-
"@swc/helpers": "^0.5.1",
26-
"use-disposable": "^1.0.1"
25+
"@swc/helpers": "^0.5.1"
2726
},
2827
"peerDependencies": {
2928
"@types/react": ">=16.14.0 <19.0.0",

packages/react-components/react-portal/library/src/components/Portal/__snapshots__/Portal.test.tsx.snap

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,8 @@ exports[`Portal renders a default state 1`] = `
77
dir="ltr"
88
>
99
test
10+
<span
11+
hidden=""
12+
/>
1013
</div>
1114
`;

packages/react-components/react-portal/library/src/components/Portal/renderPortal.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,18 @@ import type { PortalState } from './Portal.types';
88
export const renderPortal_unstable = (state: PortalState): React.ReactElement => {
99
return (
1010
<span hidden ref={state.virtualParentRootRef}>
11-
{state.mountNode && ReactDOM.createPortal(state.children, state.mountNode)}
11+
{state.mountNode &&
12+
ReactDOM.createPortal(
13+
<>
14+
{state.children}
15+
{/* Heads up!
16+
* This node exists only to ensure that the portal is not empty as we rely on that in `usePortalMountNode`
17+
* hook for React 18+.
18+
*/}
19+
<span hidden />
20+
</>,
21+
state.mountNode,
22+
)}
1223
</span>
1324
);
1425
};

packages/react-components/react-portal/library/src/components/Portal/usePortalMountNode.test.tsx

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,9 @@ describe('usePortalMountNode', () => {
88
it('creates an element and attaches it to "document.body"', () => {
99
const { result } = renderHook(() => usePortalMountNode({}));
1010

11-
expect(result.current).toBeInstanceOf(HTMLDivElement);
12-
expect(result.current).toHaveAttribute('data-portal-node', 'true');
13-
expect(document.body.contains(result.current)).toBeTruthy();
11+
expect(result.current?.tagName).toBe('DIV');
12+
expect(result.current?.dataset.portalNode).toBe('true');
13+
expect(result.current?.parentElement).toBe(document.body);
1414
});
1515

1616
it('creates an element and attaches it to "mountNode"', () => {
@@ -19,8 +19,9 @@ describe('usePortalMountNode', () => {
1919
wrapper: (props: { children?: React.ReactNode }) => <PortalMountNodeProvider {...props} value={mountNode} />,
2020
});
2121

22-
expect(result.current).toBeInstanceOf(HTMLDivElement);
23-
expect(mountNode.contains(result.current)).toBeTruthy();
22+
expect(result.current?.tagName).toBe('DIV');
23+
expect(result.current?.dataset.portalNode).toBe('true');
24+
expect(result.current?.parentElement).toBe(mountNode);
2425
});
2526

2627
it('applies classes to an element', () => {

0 commit comments

Comments
 (0)