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
Expand Up @@ -114,6 +114,23 @@ function MyComponent(props) {

Having multiple applications using `FluentProvider` on a single web page can cause interop problems when they come from different bundles, more details in [microsoft/fluentui#26496](https://github.com/microsoft/fluentui/pull/26496).

### React 18

It's possible to handle id prefixing natively with React 18 using the [`createRoot`](https://react.dev/reference/react-dom/client/createRoot#parameters) API by configuring `identifierPrefix` on it:

```jsx
import * as React from 'react';
import { createRoot } from 'react-dom';

const root = createRoot(document.getElementById('root'), {
identifierPrefix: 'APP1-',
});

root.render(<App />);
```

### React 16 & 17

Adding the `IdPrefixProvider` component around the `FluentProvider` will resolve the issue of losing styling on components and IDs collisions in an application.

```jsx
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "patch",
"comment": "feat(FluentProvider): emit errors on duplicate IDs",
"packageName": "@fluentui/react-provider",
"email": "olfedias@microsoft.com",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import {
canUseDOM as _canUseDOM,
resetIdsForTests,
useIsomorphicLayoutEffect as _useIsomorphicLayoutEffect,
} from '@fluentui/react-utilities';
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import { renderToStaticMarkup } from 'react-dom/server';

import { FluentProvider } from './FluentProvider';

jest.mock('@fluentui/react-utilities', () => {
const utilities = jest.requireActual('@fluentui/react-utilities');

return {
...utilities,
canUseDOM: jest.fn().mockImplementation(utilities.canUseDOM),
useIsomorphicLayoutEffect: jest.fn().mockImplementation(utilities.useIsomorphicLayoutEffect),
};
});

const canUseDOM = _canUseDOM as jest.MockedFunction<typeof _canUseDOM>;
const useIsomorphicLayoutEffect = _useIsomorphicLayoutEffect as jest.MockedFunction<typeof _useIsomorphicLayoutEffect>;

// Heads up!
//
// Tests in this file are specific to hydration scenarios
// They have to be run in DOM as otherwise hydration is not possible

const SSR_TARGET_DOCUMENT = null as unknown as undefined;

function renderHTML(element: React.ReactElement) {
// Mocking defaults to simulate SSR environment
canUseDOM.mockReturnValueOnce(false);
useIsomorphicLayoutEffect.mockImplementationOnce(React.useEffect);

const html = renderToStaticMarkup(element);

// IDs are reset to avoid conflicts between SSR and hydration
resetIdsForTests();

return html;
}

describe('FluentProvider (hydration)', () => {
// eslint-disable-next-line @typescript-eslint/no-empty-function
const noop = () => {};
let logErrorSpy: jest.SpyInstance;

beforeEach(() => {
logErrorSpy = jest.spyOn(console, 'error').mockImplementation(noop);
});

afterEach(() => {
jest.clearAllMocks();
resetIdsForTests();
});

it('should not emit an error on hydration', () => {
const container = document.createElement('div');

document.body.appendChild(container);
container.innerHTML = renderHTML(<FluentProvider targetDocument={SSR_TARGET_DOCUMENT} />);

ReactDOM.hydrate(<FluentProvider />, container);
expect(logErrorSpy).toHaveBeenCalledTimes(0);
});
});
Original file line number Diff line number Diff line change
@@ -1,18 +1,21 @@
import { teamsLightTheme } from '@fluentui/react-theme';
import { resetIdsForTests } from '@fluentui/react-utilities';
import { render } from '@testing-library/react';
import * as React from 'react';
import * as reactTestRenderer from 'react-test-renderer';

import { FluentProvider } from './FluentProvider';
import { fluentProviderClassNames } from './useFluentProviderStyles.styles';
import { isConformant } from '../../testing/isConformant';
import { teamsLightTheme } from '@fluentui/react-theme';

describe('FluentProvider', () => {
// eslint-disable-next-line @typescript-eslint/no-empty-function
const noop = () => {};
let logErrorSpy: jest.SpyInstance;

beforeEach(() => {
jest.spyOn(console, 'warn').mockImplementation(noop);
logErrorSpy = jest.spyOn(console, 'error').mockImplementation(noop);
});

isConformant({
Expand All @@ -36,6 +39,28 @@ describe('FluentProvider', () => {
expect(tree).toMatchSnapshot();
});

it(`should emit an error on duplicated IDs`, () => {
const containerA = document.createElement('div');
const containerB = document.createElement('div');

document.body.appendChild(containerA);
document.body.appendChild(containerB);

render(<FluentProvider theme={teamsLightTheme} />, { container: containerA });
expect(document.body.querySelectorAll(`.${fluentProviderClassNames.root}`)).toHaveLength(1);

// This resets IDs, so the next FluentProvider will have the same IDs as the first one
resetIdsForTests();

render(<FluentProvider theme={teamsLightTheme} />, { container: containerB });
expect(document.body.querySelectorAll(`.${fluentProviderClassNames.root}`)).toHaveLength(2);

expect(logErrorSpy).toHaveBeenCalledTimes(1);
expect(logErrorSpy).toHaveBeenCalledWith(
expect.stringContaining('@fluentui/react-provider: There are conflicting ids in your DOM.'),
);
});

it('does not render style element when not in SSR', () => {
const { container } = render(<FluentProvider theme={teamsLightTheme} />);
expect(container.querySelector('style')).toBeNull();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import type { PartialTheme } from '@fluentui/react-theme';
import type { OverridesContextValue_unstable } from '@fluentui/react-shared-contexts';
import { renderHook } from '@testing-library/react-hooks';
import * as React from 'react';

import { FluentProvider } from './FluentProvider';
import { useFluentProvider_unstable } from './useFluentProvider';
import type { PartialTheme } from '@fluentui/react-theme';
import { OverridesContextValue_unstable } from '@fluentui/react-shared-contexts';
import { FluentProviderCustomStyleHooks } from './FluentProvider.types';

describe('useFluentProvider_unstable', () => {
Expand All @@ -24,8 +24,11 @@ describe('useFluentProvider_unstable', () => {
});

expect(result.current.theme).toBe(undefined);

expect(logWarnSpy).toHaveBeenCalledTimes(2);
expect(logWarnSpy).toHaveBeenCalledWith(expect.stringContaining('FluentProvider: your "theme" is not defined !'));
expect(logWarnSpy).toHaveBeenCalledWith(
expect.stringContaining('@fluentui/react-provider: FluentProvider does not have your "theme" defined.'),
);
});

it('should merge themes', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useRenderer_unstable } from '@griffel/react';
import { useFocusVisible } from '@fluentui/react-tabster';
import {
ThemeContext_unstable as ThemeContext,
Expand All @@ -9,12 +10,11 @@ import type {
CustomStyleHooksContextValue_unstable as CustomStyleHooksContextValue,
ThemeContextValue_unstable as ThemeContextValue,
} from '@fluentui/react-shared-contexts';

import { getNativeElementProps, useMergedRefs } from '@fluentui/react-utilities';
import * as React from 'react';

import { useFluentProviderThemeStyleTag } from './useFluentProviderThemeStyleTag';
import type { FluentProviderProps, FluentProviderState } from './FluentProvider.types';
import { useRenderer_unstable } from '@griffel/react';

/**
* Create the state required to render FluentProvider.
Expand Down Expand Up @@ -48,33 +48,38 @@ export const useFluentProvider_unstable = (
theme,
overrides_unstable: overrides = {},
} = props;
const mergedTheme = shallowMerge(parentTheme, theme);

const mergedTheme = shallowMerge(parentTheme, theme);
const mergedOverrides = shallowMerge(parentOverrides, overrides);

const mergedCustomStyleHooks = shallowMerge(
parentCustomStyleHooks,
customStyleHooks_unstable,
) as CustomStyleHooksContextValue;

React.useEffect(() => {
if (process.env.NODE_ENV !== 'production' && mergedTheme === undefined) {
// eslint-disable-next-line no-console
console.warn(`
FluentProvider: your "theme" is not defined !
=============================================
Make sure your root FluentProvider has set a theme or you're setting the theme in your child FluentProvider.
`);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

const renderer = useRenderer_unstable();
const { styleTagId, rule } = useFluentProviderThemeStyleTag({
theme: mergedTheme,
targetDocument,
rendererAttributes: renderer.styleElementAttributes ?? {},
});

if (process.env.NODE_ENV !== 'production') {
// eslint-disable-next-line react-hooks/rules-of-hooks
React.useEffect(() => {
if (mergedTheme === undefined) {
// eslint-disable-next-line no-console
console.warn(
[
'@fluentui/react-provider: FluentProvider does not have your "theme" defined.',
"Make sure that your top-level FluentProvider has set a `theme` prop or you're setting the theme in your child FluentProvider.",
].join(' '),
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}

return {
applyStylesToPortals,
// eslint-disable-next-line @typescript-eslint/naming-convention
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,39 @@ export const useFluentProviderThemeStyleTag = (

const rule = `.${styleTagId} { ${cssVarsAsString} }`;

if (process.env.NODE_ENV !== 'production') {
// eslint-disable-next-line react-hooks/rules-of-hooks
React.useMemo(() => {
// Heads up!
// .useMemo() is used because it is called during render and DOM for _current_ component is not mounted yet. Also,
// this allows to do checks with strict mode enabled as .useEffect() will be called with incremented IDs because
// of double render.

if (targetDocument) {
const providerSelector = `.${fluentProviderClassNames.root}.${styleTagId}`;
const providerElements = targetDocument.querySelectorAll(providerSelector);

// In SSR, we will have DOM upfront. To avoid false positives the check on nested style tag is performed
const isSSR = targetDocument.querySelector(`${providerSelector} > style[id="${styleTagId}"]`) !== null;
const elementsCount = isSSR ? 1 : 0;

if (providerElements.length > elementsCount) {
// eslint-disable-next-line no-console
console.error(
[
'@fluentui/react-provider: There are conflicting ids in your DOM.',
'Please make sure that you configured your application properly.',
'\n',
'\n',
'Configuration guide: https://aka.ms/fluentui-conflicting-ids',
].join(' '),
);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}

useHandleSSRStyleElements(targetDocument, styleTagId);
useInsertionEffect(() => {
// The style element could already have been created during SSR - no need to recreate it
Expand Down