diff --git a/packages/itwinui-react/src/core/utils/hooks/index.ts b/packages/itwinui-react/src/core/utils/hooks/index.ts index f125b747f49..c60c0d923d6 100644 --- a/packages/itwinui-react/src/core/utils/hooks/index.ts +++ b/packages/itwinui-react/src/core/utils/hooks/index.ts @@ -16,3 +16,4 @@ export * from './useIsomorphicLayoutEffect.js'; export * from './useIsClient.js'; export * from './useId.js'; export * from './useControlledState.js'; +export * from './useSyncExternalStore.js'; diff --git a/packages/itwinui-react/src/core/utils/hooks/useMediaQuery.ts b/packages/itwinui-react/src/core/utils/hooks/useMediaQuery.ts index 53a1bcd4caa..e08892b2c51 100644 --- a/packages/itwinui-react/src/core/utils/hooks/useMediaQuery.ts +++ b/packages/itwinui-react/src/core/utils/hooks/useMediaQuery.ts @@ -3,36 +3,23 @@ * See LICENSE.md in the project root for license terms and full copyright notice. *--------------------------------------------------------------------------------------------*/ import * as React from 'react'; -import { getWindow } from '../functions/index.js'; -import { useLayoutEffect } from './useIsomorphicLayoutEffect.js'; +import { useSyncExternalStore } from './useSyncExternalStore.js'; export const useMediaQuery = (queryString: string) => { - const [matches, setMatches] = React.useState(); - - useLayoutEffect(() => { - const mediaQueryList = getWindow()?.matchMedia?.(queryString); - const handleChange = ({ matches }: MediaQueryListEvent) => - setMatches(matches); - - if (mediaQueryList != undefined) { - setMatches(mediaQueryList.matches); - try { - mediaQueryList.addEventListener('change', handleChange); - } catch { - // Safari 13 fallback - mediaQueryList.addListener?.(handleChange); - } - } - - return () => { - try { - mediaQueryList?.removeEventListener('change', handleChange); - } catch { - // Safari 13 fallback - mediaQueryList?.removeListener?.(handleChange); - } - }; + const getSnapshot = React.useCallback(() => { + return typeof window !== 'undefined' + ? window.matchMedia?.(queryString).matches + : undefined; }, [queryString]); - return !!matches; + const subscribe = React.useCallback( + (onChange: () => void) => { + const mediaQueryList = window.matchMedia?.(queryString); + mediaQueryList?.addEventListener?.('change', onChange); + return () => mediaQueryList?.removeEventListener?.('change', onChange); + }, + [queryString], + ); + + return useSyncExternalStore(subscribe, getSnapshot, () => undefined); }; diff --git a/packages/itwinui-react/src/core/utils/hooks/useSyncExternalStore.ts b/packages/itwinui-react/src/core/utils/hooks/useSyncExternalStore.ts new file mode 100644 index 00000000000..eccfcb95262 --- /dev/null +++ b/packages/itwinui-react/src/core/utils/hooks/useSyncExternalStore.ts @@ -0,0 +1,53 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Bentley Systems, Incorporated. All rights reserved. + * See LICENSE.md in the project root for license terms and full copyright notice. + *--------------------------------------------------------------------------------------------*/ +import * as React from 'react'; +const _React = React; // prevent bundlers from stripping the namespace import + +/** + * Wrapper around `React.useSyncExternalStore` that uses a shim for React 17. + */ +export const useSyncExternalStore = + _React.useSyncExternalStore || useSyncExternalStoreShim; + +// ---------------------------------------------------------------------------- + +/** + * The shim below is adapted from React's source to make it ESM-compatible. + * + * Note: This does not use `getServerSnapshot` at all, because there is + * apparently no way to check "hydrating" state in pre-18. + * + * @see https://github.com/facebook/react/tree/main/packages/use-sync-external-store + */ +function useSyncExternalStoreShim( + subscribe: (onSubscribe: () => void) => () => void, + getSnapshot: () => T, +): T { + const value = getSnapshot(); + const [{ instance }, forceUpdate] = React.useState({ + instance: { value, getSnapshot }, + }); + + React.useLayoutEffect(() => { + instance.value = value; + instance.getSnapshot = getSnapshot; + + if (!Object.is(value, getSnapshot())) { + forceUpdate({ instance }); + } + }, [subscribe, value, getSnapshot]); // eslint-disable-line + + React.useEffect(() => { + const synchronize = () => { + if (!Object.is(instance.value, instance.getSnapshot())) { + forceUpdate({ instance }); + } + }; + synchronize(); + return subscribe(synchronize); + }, [subscribe]); // eslint-disable-line + + return value; +}