Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
Comment thread
dmytrokirpa marked this conversation as resolved.
"type": "patch",
"comment": "refactor: remove Griffel dependency from usePortalMountNode",
"packageName": "@fluentui/react-portal",
"email": "dmytrokirpa@microsoft.com",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,9 @@ import {
useFluent_unstable as useFluent,
usePortalMountNode as usePortalMountNodeContext,
} from '@fluentui/react-shared-contexts';
import { mergeClasses } from '@griffel/react';
import { useFocusVisible } from '@fluentui/react-tabster';

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

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

Expand Down Expand Up @@ -198,16 +197,14 @@ const useModernElementFactory: UseElementFactory = options => {
return;
}

const classesToApply = className.split(' ').filter(Boolean);

elementProxy.classList.add(...classesToApply);
elementProxy.setAttribute('class', className);
elementProxy.setAttribute('dir', dir);
elementProxy.setAttribute('data-portal-node', 'true');

focusVisibleRef.current = elementProxy;

return () => {
elementProxy.classList.remove(...classesToApply);
elementProxy.removeAttribute('class');
elementProxy.removeAttribute('dir');
};
}, [className, dir, elementProxy, focusVisibleRef]);
Expand Down Expand Up @@ -243,17 +240,18 @@ export const usePortalMountNode = (options: UsePortalMountNodeOptions): HTMLElem

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

const factoryOptions: UseElementFactoryOptions = {
dir,
disabled: options.disabled,
focusVisibleRef,

className: mergeClasses(themeClassName, classes.root, options.className),
className: [themeClassName, options.className].filter(Boolean).join(' '),
targetNode: mountNode ?? targetDocument?.body,
};

usePortalMountNodeStyles(options.disabled);

return useElementFactory(factoryOptions);
};

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { renderHook } from '@testing-library/react-hooks';
import { Provider_unstable as Provider } from '@fluentui/react-shared-contexts';
import * as React from 'react';

import { usePortalMountNodeStyles, PORTAL_STYLE_ELEMENT_ID, setPortalRefCount } from './usePortalMountNodeStyles';

function queryStyleElement(): HTMLStyleElement | null {
return document.head.querySelector(`#${PORTAL_STYLE_ELEMENT_ID}`);
}

function createWrapper(targetDocument: Document | undefined) {
return (props: { children?: React.ReactNode }) => (
<Provider value={{ dir: 'ltr', targetDocument }}>{props.children}</Provider>
);
}

describe('usePortalMountNodeStyles', () => {
afterEach(() => {
// Clean up any leftover style elements and reset the ref count
queryStyleElement()?.remove();
setPortalRefCount(document, 0);
});

it('injects a <style> element into document.head when enabled', () => {
expect(queryStyleElement()).toBeNull();

renderHook(() => usePortalMountNodeStyles(false));

const style = queryStyleElement();
expect(style).not.toBeNull();
expect(style!.parentElement).toBe(document.head);
});

it('does not inject a <style> element when disabled', () => {
renderHook(() => usePortalMountNodeStyles(true));

expect(queryStyleElement()).toBeNull();
});

it('does not inject a <style> element when targetDocument is undefined', () => {
renderHook(() => usePortalMountNodeStyles(false), {
wrapper: createWrapper(undefined),
});

expect(queryStyleElement()).toBeNull();
});

it('removes the <style> element on unmount', () => {
const { unmount } = renderHook(() => usePortalMountNodeStyles(false));

expect(queryStyleElement()).not.toBeNull();

unmount();

expect(queryStyleElement()).toBeNull();
});

it('shares a single <style> element across multiple consumers', () => {
const hook1 = renderHook(() => usePortalMountNodeStyles(false));
const hook2 = renderHook(() => usePortalMountNodeStyles(false));

const allStyles = document.head.querySelectorAll(`#${PORTAL_STYLE_ELEMENT_ID}`);
expect(allStyles.length).toBe(1);

// Unmounting one consumer keeps the style
hook1.unmount();
expect(queryStyleElement()).not.toBeNull();

// Unmounting the last consumer removes it
hook2.unmount();
expect(queryStyleElement()).toBeNull();
});

it('injects the style rule via insertRule', () => {
renderHook(() => usePortalMountNodeStyles(false));

const style = queryStyleElement();
expect(style).not.toBeNull();
expect(style!.sheet).not.toBeNull();
expect(style!.sheet!.cssRules.length).toBe(1);
expect(style!.sheet!.cssRules[0].cssText).toContain('[data-portal-node]');
});

it('prepends the <style> element as the first child of head', () => {
// Add an existing element to head
const existing = document.createElement('link');
document.head.appendChild(existing);

renderHook(() => usePortalMountNodeStyles(false));

const style = queryStyleElement();
expect(style).not.toBeNull();
expect(document.head.firstElementChild).toBe(style);

existing.remove();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
'use client';

import * as React from 'react';
import { useFluent_unstable as useFluent } from '@fluentui/react-shared-contexts';
import { useIsomorphicLayoutEffect } from '@fluentui/react-utilities';

// String concatenation is used to prevent bundlers to complain with older versions of React
const useInsertionEffect = (React as never)['useInsertion' + 'Effect']
? (React as never)['useInsertion' + 'Effect']
: useIsomorphicLayoutEffect;

// Symbol used as a "private" property key on Document to store the active portal reference count.
// Symbol.for() registers in the global Symbol registry so the same key is shared across bundles
// (e.g. when multiple copies of this module are loaded in the same page).
// Storing state directly on the document avoids any WeakMap cross-reference issues and is safe
// across multiple documents (e.g. iframes) because each document object carries its own counter.
const PORTAL_STYLE_REF_COUNT = Symbol.for('fui-portal-style-ref-count');

type DocumentWithPortalCounter = Document & { [PORTAL_STYLE_REF_COUNT]?: number };

// Creates new stacking context to prevent z-index issues
// https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_positioned_layout/Understanding_z-index/Stacking_context
//
// Also keeps a portal on top of a page to prevent scrollbars from appearing
const PORTAL_MOUNT_NODE_STYLE_RULE = `[data-portal-node]{position:absolute;top:0;left:0;right:0;z-index:1000000}`;

// ID used to identify the injected portal mount node <style> element in a document.
// Only one such element exists per document, so an id is appropriate.
export const PORTAL_STYLE_ELEMENT_ID = 'fui-portal-styles';

export function getPortalRefCount(targetDocument: Document): number {
return (targetDocument as DocumentWithPortalCounter)[PORTAL_STYLE_REF_COUNT] ?? 0;
}

export function setPortalRefCount(targetDocument: Document, count: number): void {
(targetDocument as DocumentWithPortalCounter)[PORTAL_STYLE_REF_COUNT] = count;
}

function injectPortalMountNodeStyles(targetDocument: Document): void {
const currentCount = getPortalRefCount(targetDocument);
if (currentCount > 0) {
setPortalRefCount(targetDocument, currentCount + 1);
return;
}
const style = targetDocument.createElement('style');
style.id = PORTAL_STYLE_ELEMENT_ID;
// Prepend so that consumer class names (applied later in document order) can override these
// defaults via CSS source order at equal specificity — the same cascade behaviour as before.
// Both prepend and append trigger one style recalculation; position in <head> does not change
// the number of recalcs.
targetDocument.head.prepend(style);
// sheet is available after the element is inserted into the document
style.sheet?.insertRule(PORTAL_MOUNT_NODE_STYLE_RULE);
setPortalRefCount(targetDocument, 1);
}

function ejectPortalMountNodeStyles(targetDocument: Document): void {
const currentCount = getPortalRefCount(targetDocument);
if (currentCount === 0) {
return;
}
const newCount = currentCount - 1;
if (newCount === 0) {
targetDocument.head.querySelector(`#${PORTAL_STYLE_ELEMENT_ID}`)?.remove();
}
setPortalRefCount(targetDocument, newCount);
}

/**
* Injects a shared <style> element for portal mount node styling into the target document,
* and removes it when the last consumer unmounts (reference counted via a Symbol property on `document`).
*/
export function usePortalMountNodeStyles(disabled: boolean | undefined): void {
const { targetDocument } = useFluent();

useInsertionEffect!(() => {
if (disabled || !targetDocument) {
return;
}
injectPortalMountNodeStyles(targetDocument);
return () => ejectPortalMountNodeStyles(targetDocument);
}, [disabled, targetDocument]);
}
Loading