66} from '@fluentui/react-shared-contexts' ;
77import { mergeClasses } from '@griffel/react' ;
88import { useFocusVisible } from '@fluentui/react-tabster' ;
9+ import { useDisposable } from 'use-disposable' ;
910
1011import { 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} ;
0 commit comments