Skip to content

Commit 48ff10f

Browse files
dmytrokirpaCopilotlayershifter
authored
refactor(react-portal): remove Griffel dependency from usePortalMountNode (#35994)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: layershifter <14183168+layershifter@users.noreply.github.com>
1 parent e367467 commit 48ff10f

5 files changed

Lines changed: 193 additions & 26 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"type": "patch",
3+
"comment": "refactor: remove Griffel dependency from usePortalMountNode",
4+
"packageName": "@fluentui/react-portal",
5+
"email": "dmytrokirpa@microsoft.com",
6+
"dependentChangeType": "patch"
7+
}

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

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,9 @@ import {
66
useFluent_unstable as useFluent,
77
usePortalMountNode as usePortalMountNodeContext,
88
} from '@fluentui/react-shared-contexts';
9-
import { mergeClasses } from '@griffel/react';
109
import { useFocusVisible } from '@fluentui/react-tabster';
1110

12-
import { usePortalMountNodeStylesStyles } from './usePortalMountNodeStyles.styles';
11+
import { usePortalMountNodeStyles } from './usePortalMountNodeStyles';
1312

1413
const useInsertionEffect = (React as never)['useInsertion' + 'Effect'] as typeof React.useLayoutEffect | undefined;
1514

@@ -198,16 +197,14 @@ const useModernElementFactory: UseElementFactory = options => {
198197
return;
199198
}
200199

201-
const classesToApply = className.split(' ').filter(Boolean);
202-
203-
elementProxy.classList.add(...classesToApply);
200+
elementProxy.setAttribute('class', className);
204201
elementProxy.setAttribute('dir', dir);
205202
elementProxy.setAttribute('data-portal-node', 'true');
206203

207204
focusVisibleRef.current = elementProxy;
208205

209206
return () => {
210-
elementProxy.classList.remove(...classesToApply);
207+
elementProxy.removeAttribute('class');
211208
elementProxy.removeAttribute('dir');
212209
};
213210
}, [className, dir, elementProxy, focusVisibleRef]);
@@ -243,17 +240,18 @@ export const usePortalMountNode = (options: UsePortalMountNodeOptions): HTMLElem
243240

244241
// eslint-disable-next-line @typescript-eslint/no-deprecated
245242
const focusVisibleRef = useFocusVisible<HTMLDivElement>() as React.MutableRefObject<HTMLElement | null>;
246-
const classes = usePortalMountNodeStylesStyles();
247243
const themeClassName = useThemeClassName();
248244

249245
const factoryOptions: UseElementFactoryOptions = {
250246
dir,
251247
disabled: options.disabled,
252248
focusVisibleRef,
253249

254-
className: mergeClasses(themeClassName, classes.root, options.className),
250+
className: [themeClassName, options.className].filter(Boolean).join(' '),
255251
targetNode: mountNode ?? targetDocument?.body,
256252
};
257253

254+
usePortalMountNodeStyles(options.disabled);
255+
258256
return useElementFactory(factoryOptions);
259257
};

packages/react-components/react-portal/library/src/components/Portal/usePortalMountNodeStyles.styles.ts

Lines changed: 0 additions & 18 deletions
This file was deleted.
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { renderHook } from '@testing-library/react-hooks';
2+
import { Provider_unstable as Provider } from '@fluentui/react-shared-contexts';
3+
import * as React from 'react';
4+
5+
import { usePortalMountNodeStyles, PORTAL_STYLE_ELEMENT_ID, setPortalRefCount } from './usePortalMountNodeStyles';
6+
7+
function queryStyleElement(): HTMLStyleElement | null {
8+
return document.head.querySelector(`#${PORTAL_STYLE_ELEMENT_ID}`);
9+
}
10+
11+
function createWrapper(targetDocument: Document | undefined) {
12+
return (props: { children?: React.ReactNode }) => (
13+
<Provider value={{ dir: 'ltr', targetDocument }}>{props.children}</Provider>
14+
);
15+
}
16+
17+
describe('usePortalMountNodeStyles', () => {
18+
afterEach(() => {
19+
// Clean up any leftover style elements and reset the ref count
20+
queryStyleElement()?.remove();
21+
setPortalRefCount(document, 0);
22+
});
23+
24+
it('injects a <style> element into document.head when enabled', () => {
25+
expect(queryStyleElement()).toBeNull();
26+
27+
renderHook(() => usePortalMountNodeStyles(false));
28+
29+
const style = queryStyleElement();
30+
expect(style).not.toBeNull();
31+
expect(style!.parentElement).toBe(document.head);
32+
});
33+
34+
it('does not inject a <style> element when disabled', () => {
35+
renderHook(() => usePortalMountNodeStyles(true));
36+
37+
expect(queryStyleElement()).toBeNull();
38+
});
39+
40+
it('does not inject a <style> element when targetDocument is undefined', () => {
41+
renderHook(() => usePortalMountNodeStyles(false), {
42+
wrapper: createWrapper(undefined),
43+
});
44+
45+
expect(queryStyleElement()).toBeNull();
46+
});
47+
48+
it('removes the <style> element on unmount', () => {
49+
const { unmount } = renderHook(() => usePortalMountNodeStyles(false));
50+
51+
expect(queryStyleElement()).not.toBeNull();
52+
53+
unmount();
54+
55+
expect(queryStyleElement()).toBeNull();
56+
});
57+
58+
it('shares a single <style> element across multiple consumers', () => {
59+
const hook1 = renderHook(() => usePortalMountNodeStyles(false));
60+
const hook2 = renderHook(() => usePortalMountNodeStyles(false));
61+
62+
const allStyles = document.head.querySelectorAll(`#${PORTAL_STYLE_ELEMENT_ID}`);
63+
expect(allStyles.length).toBe(1);
64+
65+
// Unmounting one consumer keeps the style
66+
hook1.unmount();
67+
expect(queryStyleElement()).not.toBeNull();
68+
69+
// Unmounting the last consumer removes it
70+
hook2.unmount();
71+
expect(queryStyleElement()).toBeNull();
72+
});
73+
74+
it('injects the style rule via insertRule', () => {
75+
renderHook(() => usePortalMountNodeStyles(false));
76+
77+
const style = queryStyleElement();
78+
expect(style).not.toBeNull();
79+
expect(style!.sheet).not.toBeNull();
80+
expect(style!.sheet!.cssRules.length).toBe(1);
81+
expect(style!.sheet!.cssRules[0].cssText).toContain('[data-portal-node]');
82+
});
83+
84+
it('prepends the <style> element as the first child of head', () => {
85+
// Add an existing element to head
86+
const existing = document.createElement('link');
87+
document.head.appendChild(existing);
88+
89+
renderHook(() => usePortalMountNodeStyles(false));
90+
91+
const style = queryStyleElement();
92+
expect(style).not.toBeNull();
93+
expect(document.head.firstElementChild).toBe(style);
94+
95+
existing.remove();
96+
});
97+
});
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
'use client';
2+
3+
import * as React from 'react';
4+
import { useFluent_unstable as useFluent } from '@fluentui/react-shared-contexts';
5+
import { useIsomorphicLayoutEffect } from '@fluentui/react-utilities';
6+
7+
// String concatenation is used to prevent bundlers to complain with older versions of React
8+
const useInsertionEffect = (React as never)['useInsertion' + 'Effect']
9+
? (React as never)['useInsertion' + 'Effect']
10+
: useIsomorphicLayoutEffect;
11+
12+
// Symbol used as a "private" property key on Document to store the active portal reference count.
13+
// Symbol.for() registers in the global Symbol registry so the same key is shared across bundles
14+
// (e.g. when multiple copies of this module are loaded in the same page).
15+
// Storing state directly on the document avoids any WeakMap cross-reference issues and is safe
16+
// across multiple documents (e.g. iframes) because each document object carries its own counter.
17+
const PORTAL_STYLE_REF_COUNT = Symbol.for('fui-portal-style-ref-count');
18+
19+
type DocumentWithPortalCounter = Document & { [PORTAL_STYLE_REF_COUNT]?: number };
20+
21+
// Creates new stacking context to prevent z-index issues
22+
// https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_positioned_layout/Understanding_z-index/Stacking_context
23+
//
24+
// Also keeps a portal on top of a page to prevent scrollbars from appearing
25+
const PORTAL_MOUNT_NODE_STYLE_RULE = `[data-portal-node]{position:absolute;top:0;left:0;right:0;z-index:1000000}`;
26+
27+
// ID used to identify the injected portal mount node <style> element in a document.
28+
// Only one such element exists per document, so an id is appropriate.
29+
export const PORTAL_STYLE_ELEMENT_ID = 'fui-portal-styles';
30+
31+
export function getPortalRefCount(targetDocument: Document): number {
32+
return (targetDocument as DocumentWithPortalCounter)[PORTAL_STYLE_REF_COUNT] ?? 0;
33+
}
34+
35+
export function setPortalRefCount(targetDocument: Document, count: number): void {
36+
(targetDocument as DocumentWithPortalCounter)[PORTAL_STYLE_REF_COUNT] = count;
37+
}
38+
39+
function injectPortalMountNodeStyles(targetDocument: Document): void {
40+
const currentCount = getPortalRefCount(targetDocument);
41+
if (currentCount > 0) {
42+
setPortalRefCount(targetDocument, currentCount + 1);
43+
return;
44+
}
45+
const style = targetDocument.createElement('style');
46+
style.id = PORTAL_STYLE_ELEMENT_ID;
47+
// Prepend so that consumer class names (applied later in document order) can override these
48+
// defaults via CSS source order at equal specificity — the same cascade behaviour as before.
49+
// Both prepend and append trigger one style recalculation; position in <head> does not change
50+
// the number of recalcs.
51+
targetDocument.head.prepend(style);
52+
// sheet is available after the element is inserted into the document
53+
style.sheet?.insertRule(PORTAL_MOUNT_NODE_STYLE_RULE);
54+
setPortalRefCount(targetDocument, 1);
55+
}
56+
57+
function ejectPortalMountNodeStyles(targetDocument: Document): void {
58+
const currentCount = getPortalRefCount(targetDocument);
59+
if (currentCount === 0) {
60+
return;
61+
}
62+
const newCount = currentCount - 1;
63+
if (newCount === 0) {
64+
targetDocument.head.querySelector(`#${PORTAL_STYLE_ELEMENT_ID}`)?.remove();
65+
}
66+
setPortalRefCount(targetDocument, newCount);
67+
}
68+
69+
/**
70+
* Injects a shared <style> element for portal mount node styling into the target document,
71+
* and removes it when the last consumer unmounts (reference counted via a Symbol property on `document`).
72+
*/
73+
export function usePortalMountNodeStyles(disabled: boolean | undefined): void {
74+
const { targetDocument } = useFluent();
75+
76+
useInsertionEffect!(() => {
77+
if (disabled || !targetDocument) {
78+
return;
79+
}
80+
injectPortalMountNodeStyles(targetDocument);
81+
return () => ejectPortalMountNodeStyles(targetDocument);
82+
}, [disabled, targetDocument]);
83+
}

0 commit comments

Comments
 (0)