diff --git a/apps/public-docsite-v9/src/Concepts/AdvancedConfiguration.stories.mdx b/apps/public-docsite-v9/src/Concepts/AdvancedConfiguration.stories.mdx
index 2c4f7a9df02a1..02adae2243804 100644
--- a/apps/public-docsite-v9/src/Concepts/AdvancedConfiguration.stories.mdx
+++ b/apps/public-docsite-v9/src/Concepts/AdvancedConfiguration.stories.mdx
@@ -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();
+```
+
+### 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
diff --git a/change/@fluentui-react-provider-c1192c64-622d-40a5-9732-ee38cc84acef.json b/change/@fluentui-react-provider-c1192c64-622d-40a5-9732-ee38cc84acef.json
new file mode 100644
index 0000000000000..92e794c142cff
--- /dev/null
+++ b/change/@fluentui-react-provider-c1192c64-622d-40a5-9732-ee38cc84acef.json
@@ -0,0 +1,7 @@
+{
+ "type": "patch",
+ "comment": "feat(FluentProvider): emit errors on duplicate IDs",
+ "packageName": "@fluentui/react-provider",
+ "email": "olfedias@microsoft.com",
+ "dependentChangeType": "patch"
+}
diff --git a/packages/react-components/react-provider/src/components/FluentProvider/FluentProvider-hydrate.test.tsx b/packages/react-components/react-provider/src/components/FluentProvider/FluentProvider-hydrate.test.tsx
new file mode 100644
index 0000000000000..cf6a9f940d2a7
--- /dev/null
+++ b/packages/react-components/react-provider/src/components/FluentProvider/FluentProvider-hydrate.test.tsx
@@ -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;
+const useIsomorphicLayoutEffect = _useIsomorphicLayoutEffect as jest.MockedFunction;
+
+// 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();
+
+ ReactDOM.hydrate(, container);
+ expect(logErrorSpy).toHaveBeenCalledTimes(0);
+ });
+});
diff --git a/packages/react-components/react-provider/src/components/FluentProvider/FluentProvider.test.tsx b/packages/react-components/react-provider/src/components/FluentProvider/FluentProvider.test.tsx
index edf895890f0d7..e062340a26a2e 100644
--- a/packages/react-components/react-provider/src/components/FluentProvider/FluentProvider.test.tsx
+++ b/packages/react-components/react-provider/src/components/FluentProvider/FluentProvider.test.tsx
@@ -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({
@@ -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(, { 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(, { 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();
expect(container.querySelector('style')).toBeNull();
diff --git a/packages/react-components/react-provider/src/components/FluentProvider/useFluentProvider.test.tsx b/packages/react-components/react-provider/src/components/FluentProvider/useFluentProvider.test.tsx
index 15218eca48b1f..76476bd3ef430 100644
--- a/packages/react-components/react-provider/src/components/FluentProvider/useFluentProvider.test.tsx
+++ b/packages/react-components/react-provider/src/components/FluentProvider/useFluentProvider.test.tsx
@@ -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', () => {
@@ -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', () => {
diff --git a/packages/react-components/react-provider/src/components/FluentProvider/useFluentProvider.ts b/packages/react-components/react-provider/src/components/FluentProvider/useFluentProvider.ts
index eea3f32a31788..c118d624e0d10 100644
--- a/packages/react-components/react-provider/src/components/FluentProvider/useFluentProvider.ts
+++ b/packages/react-components/react-provider/src/components/FluentProvider/useFluentProvider.ts
@@ -1,3 +1,4 @@
+import { useRenderer_unstable } from '@griffel/react';
import { useFocusVisible } from '@fluentui/react-tabster';
import {
ThemeContext_unstable as ThemeContext,
@@ -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.
@@ -48,8 +48,8 @@ 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(
@@ -57,24 +57,29 @@ export const useFluentProvider_unstable = (
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
diff --git a/packages/react-components/react-provider/src/components/FluentProvider/useFluentProviderThemeStyleTag.ts b/packages/react-components/react-provider/src/components/FluentProvider/useFluentProviderThemeStyleTag.ts
index 3751196dcea89..23bf2e3696c10 100644
--- a/packages/react-components/react-provider/src/components/FluentProvider/useFluentProviderThemeStyleTag.ts
+++ b/packages/react-components/react-provider/src/components/FluentProvider/useFluentProviderThemeStyleTag.ts
@@ -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