Skip to content

Commit d59c32a

Browse files
authored
Revert "chore(react-portal): support for React 19" (#34737)
1 parent 151cd44 commit d59c32a

File tree

6 files changed

+69
-192
lines changed

6 files changed

+69
-192
lines changed
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"type": "patch",
3+
"comment": "Revert \"chore(react-portal): support for React 19\"",
4+
"packageName": "@fluentui/react-portal",
5+
"email": "olfedias@microsoft.com",
6+
"dependentChangeType": "patch"
7+
}

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@
2222
"@fluentui/react-tabster": "^9.25.3",
2323
"@fluentui/react-utilities": "^9.22.0",
2424
"@griffel/react": "^1.5.22",
25-
"@swc/helpers": "^0.5.1"
25+
"@swc/helpers": "^0.5.1",
26+
"use-disposable": "^1.0.1"
2627
},
2728
"peerDependencies": {
2829
"@types/react": ">=16.14.0 <19.0.0",

packages/react-components/react-portal/library/src/components/Portal/usePortal.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ export const usePortal_unstable = (props: PortalProps): PortalState => {
8080
setVirtualParent(mountNode, undefined);
8181
};
8282
}
83-
}, [mountNode]);
83+
}, [virtualParentRootRef, mountNode]);
8484

8585
return state;
8686
};

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

Lines changed: 5 additions & 6 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?.tagName).toBe('DIV');
12-
expect(result.current?.dataset.portalNode).toBe('true');
13-
expect(result.current?.parentElement).toBe(document.body);
11+
expect(result.current).toBeInstanceOf(HTMLDivElement);
12+
expect(result.current).toHaveAttribute('data-portal-node', 'true');
13+
expect(document.body.contains(result.current)).toBeTruthy();
1414
});
1515

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

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

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

packages/react-components/react-portal/library/src/components/Portal/usePortalMountNode.ts

Lines changed: 49 additions & 184 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
} from '@fluentui/react-shared-contexts';
77
import { mergeClasses } from '@griffel/react';
88
import { useFocusVisible } from '@fluentui/react-tabster';
9+
import { useDisposable } from 'use-disposable';
910

1011
import { usePortalMountNodeStylesStyles } from './usePortalMountNodeStyles.styles';
1112

@@ -20,180 +21,6 @@ export type UsePortalMountNodeOptions = {
2021
className?: string;
2122
};
2223

23-
type UseElementFactoryOptions = {
24-
className: string;
25-
dir: string;
26-
disabled: boolean | undefined;
27-
focusVisibleRef: React.MutableRefObject<HTMLElement | null>;
28-
targetNode: HTMLElement | ShadowRoot | undefined;
29-
};
30-
type UseElementFactory = (options: UseElementFactoryOptions) => HTMLDivElement | null;
31-
32-
/**
33-
* Legacy element factory for React 17 and below. It's not safe for concurrent rendering.
34-
*
35-
* Creates a new element on a "document.body" to mount portals.
36-
*/
37-
const useLegacyElementFactory: UseElementFactory = options => {
38-
const { className, dir, focusVisibleRef, targetNode } = options;
39-
40-
const targetElement = React.useMemo(() => {
41-
if (targetNode === undefined || options.disabled) {
42-
return null;
43-
}
44-
45-
const element = targetNode.ownerDocument.createElement('div');
46-
targetNode.appendChild(element);
47-
48-
return element;
49-
}, [targetNode, options.disabled]);
50-
51-
// Heads up!
52-
// This useMemo() call is intentional for React 17 & below.
53-
//
54-
// We don't want to re-create the portal element when its attributes change. This also cannot not be done in an effect
55-
// because, changing the value of CSS variables after an initial mount will trigger interesting CSS side effects like
56-
// transitions.
57-
React.useMemo(() => {
58-
if (!targetElement) {
59-
return;
60-
}
61-
62-
targetElement.className = className;
63-
targetElement.setAttribute('dir', dir);
64-
targetElement.setAttribute('data-portal-node', 'true');
65-
66-
// eslint-disable-next-line react-compiler/react-compiler
67-
focusVisibleRef.current = targetElement;
68-
}, [className, dir, targetElement, focusVisibleRef]);
69-
70-
React.useEffect(() => {
71-
return () => {
72-
targetElement?.remove();
73-
};
74-
}, [targetElement]);
75-
76-
return targetElement;
77-
};
78-
79-
/**
80-
* This is a modern element factory for React 18 and above. It is safe for concurrent rendering.
81-
*
82-
* It abuses the fact that React will mount DOM once (unlike hooks), so by using a proxy we can intercept:
83-
* - the `remove()` method (we call it in `useEffect()`) and remove the element only when the portal is unmounted
84-
* - all other methods (and properties) will be called by React once a portal is mounted
85-
*/
86-
const useModernElementFactory: UseElementFactory = options => {
87-
const { className, dir, focusVisibleRef, targetNode } = options;
88-
89-
const [elementFactory] = React.useState(() => {
90-
let currentElement: HTMLDivElement | undefined = undefined;
91-
92-
function get(targetRoot: HTMLElement | ShadowRoot, forceCreation: false): HTMLDivElement | undefined;
93-
function get(targetRoot: HTMLElement | ShadowRoot, forceCreation: true): HTMLDivElement;
94-
function get(targetRoot: HTMLElement | ShadowRoot, forceCreation: boolean): HTMLDivElement | undefined {
95-
if (currentElement) {
96-
return currentElement;
97-
}
98-
99-
if (forceCreation) {
100-
currentElement = targetRoot.ownerDocument.createElement('div');
101-
targetRoot.appendChild(currentElement);
102-
}
103-
104-
return currentElement;
105-
}
106-
107-
function dispose() {
108-
if (currentElement) {
109-
currentElement.remove();
110-
currentElement = undefined;
111-
}
112-
}
113-
114-
return {
115-
get,
116-
dispose,
117-
};
118-
});
119-
120-
const elementProxy = React.useMemo(() => {
121-
if (targetNode === undefined || options.disabled) {
122-
return null;
123-
}
124-
125-
return new Proxy({} as HTMLDivElement, {
126-
get(_, property: keyof HTMLDivElement) {
127-
// Heads up!
128-
// We intercept the `remove()` method to remove the mount node only when portal has been unmounted already.
129-
if (property === 'remove') {
130-
const targetElement = elementFactory.get(targetNode, false);
131-
132-
if (targetElement) {
133-
// If the mountElement has children, the portal is still mounted
134-
const portalHasNoChildren = targetElement.childNodes.length === 0;
135-
136-
if (portalHasNoChildren) {
137-
return targetElement.remove.bind(targetElement);
138-
}
139-
}
140-
141-
return () => {
142-
// If the mountElement has children, ignore the remove call
143-
};
144-
}
145-
146-
const targetElement = elementFactory.get(targetNode, true);
147-
const targetProperty = targetElement[property];
148-
149-
if (typeof targetProperty === 'function') {
150-
return targetProperty.bind(targetElement);
151-
}
152-
153-
return targetProperty;
154-
},
155-
156-
set(_, property: keyof HTMLDivElement, value) {
157-
const targetElement = elementFactory.get(targetNode, true);
158-
159-
if (targetElement) {
160-
Object.assign(targetElement, { [property]: value });
161-
return true;
162-
}
163-
164-
return false;
165-
},
166-
});
167-
}, [elementFactory, targetNode, options.disabled]);
168-
169-
React.useEffect(() => {
170-
return () => {
171-
elementProxy?.remove();
172-
};
173-
}, [elementProxy]);
174-
175-
useInsertionEffect!(() => {
176-
if (!elementProxy) {
177-
return;
178-
}
179-
180-
const classesToApply = className.split(' ').filter(Boolean);
181-
182-
elementProxy.classList.add(...classesToApply);
183-
elementProxy.setAttribute('dir', dir);
184-
elementProxy.setAttribute('data-portal-node', 'true');
185-
186-
focusVisibleRef.current = elementProxy;
187-
188-
return () => {
189-
elementProxy.classList.remove(...classesToApply);
190-
elementProxy.removeAttribute('dir');
191-
};
192-
}, [className, dir, elementProxy, focusVisibleRef]);
193-
194-
return elementProxy;
195-
};
196-
19724
/**
19825
* Creates a new element on a "document.body" to mount portals.
19926
*/
@@ -207,20 +34,58 @@ export const usePortalMountNode = (options: UsePortalMountNodeOptions): HTMLElem
20734
const classes = usePortalMountNodeStylesStyles();
20835
const themeClassName = useThemeClassName();
20936

210-
const factoryOptions: UseElementFactoryOptions = {
211-
dir,
212-
disabled: options.disabled,
213-
focusVisibleRef,
37+
const className = mergeClasses(themeClassName, classes.root, options.className);
38+
const targetNode: HTMLElement | ShadowRoot | undefined = mountNode ?? targetDocument?.body;
39+
40+
const element = useDisposable(() => {
41+
if (targetNode === undefined || options.disabled) {
42+
return [null, () => null];
43+
}
21444

215-
className: mergeClasses(themeClassName, classes.root, options.className),
216-
targetNode: mountNode ?? targetDocument?.body,
217-
};
45+
const newElement = targetNode.ownerDocument.createElement('div');
46+
targetNode.appendChild(newElement);
47+
return [newElement, () => newElement.remove()];
48+
}, [targetNode]);
21849

21950
if (useInsertionEffect) {
22051
// eslint-disable-next-line react-hooks/rules-of-hooks
221-
return useModernElementFactory(factoryOptions);
52+
useInsertionEffect(() => {
53+
if (!element) {
54+
return;
55+
}
56+
57+
const classesToApply = className.split(' ').filter(Boolean);
58+
59+
element.classList.add(...classesToApply);
60+
element.setAttribute('dir', dir);
61+
element.setAttribute('data-portal-node', 'true');
62+
63+
focusVisibleRef.current = element;
64+
65+
return () => {
66+
element.classList.remove(...classesToApply);
67+
element.removeAttribute('dir');
68+
};
69+
}, [className, dir, element, focusVisibleRef]);
70+
} else {
71+
// This useMemo call is intentional for React 17
72+
// We don't want to re-create the portal element when its attributes change.
73+
// This also should not be done in an effect because, changing the value of css variables
74+
// after initial mount can trigger interesting CSS side effects like transitions.
75+
// eslint-disable-next-line react-hooks/rules-of-hooks
76+
React.useMemo(() => {
77+
if (!element) {
78+
return;
79+
}
80+
81+
// Force replace all classes
82+
element.className = className;
83+
element.setAttribute('dir', dir);
84+
element.setAttribute('data-portal-node', 'true');
85+
86+
focusVisibleRef.current = element;
87+
}, [className, dir, element, focusVisibleRef]);
22288
}
22389

224-
// eslint-disable-next-line react-hooks/rules-of-hooks
225-
return useLegacyElementFactory(factoryOptions);
90+
return element;
22691
};

yarn.lock

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23374,6 +23374,11 @@ use-callback-ref@^1.3.0:
2337423374
dependencies:
2337523375
tslib "^2.0.0"
2337623376

23377+
use-disposable@^1.0.1:
23378+
version "1.0.1"
23379+
resolved "https://registry.yarnpkg.com/use-disposable/-/use-disposable-1.0.1.tgz#12452ec6d41f88bf84d41792def63c9e9921fc43"
23380+
integrity sha512-5Sle1XEmK3lw3xyGqeIY7UKkiUgF+TxwUty7fTsqM5D5AxfQfo2ft+LY9xKCA+W5YbaBFbOkWfQsZY/y5JhInA==
23381+
2337723382
use-immer@^0.6.0:
2337823383
version "0.6.0"
2337923384
resolved "https://registry.yarnpkg.com/use-immer/-/use-immer-0.6.0.tgz#ca6aa5ade93018e2c65cf128d19ada54fc23f70d"

0 commit comments

Comments
 (0)