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