From 9f02d387ca39f47f8ed6477e4113fbe19a7d7bb2 Mon Sep 17 00:00:00 2001 From: Mayank <9084735+mayank99@users.noreply.github.com> Date: Tue, 26 Mar 2024 16:40:24 -0400 Subject: [PATCH 1/9] add shim for `useSyncExternalStore` --- .../src/core/utils/hooks/index.ts | 1 + .../src/core/utils/hooks/useMediaQuery.ts | 44 +++++------ .../core/utils/hooks/useSyncExternalStore.ts | 73 +++++++++++++++++++ 3 files changed, 91 insertions(+), 27 deletions(-) create mode 100644 packages/itwinui-react/src/core/utils/hooks/useSyncExternalStore.ts 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..107f50d8aa7 100644 --- a/packages/itwinui-react/src/core/utils/hooks/useMediaQuery.ts +++ b/packages/itwinui-react/src/core/utils/hooks/useMediaQuery.ts @@ -3,36 +3,26 @@ * 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(); + const [getSnapshot, subscribe] = React.useMemo(() => { + const mediaQueryList = window.matchMedia(queryString); - 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); - } - }; + return [ + () => mediaQueryList.matches, + (onChange: () => void) => { + mediaQueryList.addEventListener?.('change', onChange); + return () => { + mediaQueryList.removeEventListener?.('change', onChange); + }; + }, + ]; }, [queryString]); - return !!matches; + return useSyncExternalStore( + subscribe, + typeof window !== 'undefined' ? getSnapshot : () => undefined, + () => 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..d210bb6b528 --- /dev/null +++ b/packages/itwinui-react/src/core/utils/hooks/useSyncExternalStore.ts @@ -0,0 +1,73 @@ +/*--------------------------------------------------------------------------------------------- + * 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 + +const useSyncExternalStoreShim = + typeof document === 'undefined' ? useSESServerShim : useSESClientShim; + +/** + * Wrapper around `React.useSyncExternalStore` that uses a shim for React 17. + * + * Note: The shim 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 + */ +export const useSyncExternalStore = + _React.useSyncExternalStore || useSyncExternalStoreShim; + +// ---------------------------------------------------------------------------- + +// The shim below is adapted from the React source code to make it ESM-compatible. +// MIT License: https://github.com/facebook/react/blob/main/LICENSE + +/** @see https://github.com/facebook/react/blob/1a6d36b1a3ec43cb5700e28d7315b3aa2822365d/packages/use-sync-external-store/src/useSyncExternalStoreShimServer.js */ +function useSESServerShim(_: () => () => void, getSnapshot: () => T): T { + return getSnapshot(); +} + +/** @see https://github.com/facebook/react/blob/1a6d36b1a3ec43cb5700e28d7315b3aa2822365d/packages/use-sync-external-store/src/useSyncExternalStoreShimClient.js */ +function useSESClientShim( + subscribe: (onSubscribe: () => void) => () => void, + getSnapshot: () => T, +): T { + const value = getSnapshot(); + const [{ inst }, forceUpdate] = React.useState({ + inst: { value, getSnapshot }, + }); + + React.useLayoutEffect(() => { + inst.value = value; + inst.getSnapshot = getSnapshot; + + if (checkIfSnapshotChanged(inst)) { + forceUpdate({ inst }); + } + }, [subscribe, value, getSnapshot]); // eslint-disable-line react-hooks/exhaustive-deps + + React.useEffect(() => { + const synchronize = () => { + if (checkIfSnapshotChanged(inst)) { + forceUpdate({ inst }); + } + }; + synchronize(); + return subscribe(synchronize); + }, [subscribe]); // eslint-disable-line react-hooks/exhaustive-deps + + return value; +} + +function checkIfSnapshotChanged(inst: { value: T; getSnapshot: () => T }) { + const latestGetSnapshot = inst.getSnapshot; + const prevValue = inst.value; + try { + const nextValue = latestGetSnapshot(); + return !Object.is(prevValue, nextValue); + } catch (error) { + return true; + } +} From 338cc3f6efef47c12290d30736a4d28fc872b138 Mon Sep 17 00:00:00 2001 From: Mayank <9084735+mayank99@users.noreply.github.com> Date: Tue, 26 Mar 2024 16:50:27 -0400 Subject: [PATCH 2/9] shorten eslint comments --- .../src/core/utils/hooks/useSyncExternalStore.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/itwinui-react/src/core/utils/hooks/useSyncExternalStore.ts b/packages/itwinui-react/src/core/utils/hooks/useSyncExternalStore.ts index d210bb6b528..78b13ac5225 100644 --- a/packages/itwinui-react/src/core/utils/hooks/useSyncExternalStore.ts +++ b/packages/itwinui-react/src/core/utils/hooks/useSyncExternalStore.ts @@ -46,7 +46,7 @@ function useSESClientShim( if (checkIfSnapshotChanged(inst)) { forceUpdate({ inst }); } - }, [subscribe, value, getSnapshot]); // eslint-disable-line react-hooks/exhaustive-deps + }, [subscribe, value, getSnapshot]); // eslint-disable-line React.useEffect(() => { const synchronize = () => { @@ -56,7 +56,7 @@ function useSESClientShim( }; synchronize(); return subscribe(synchronize); - }, [subscribe]); // eslint-disable-line react-hooks/exhaustive-deps + }, [subscribe]); // eslint-disable-line return value; } From 60d9e9fa453644be804b18d471deedbd9f387b6c Mon Sep 17 00:00:00 2001 From: Mayank <9084735+mayank99@users.noreply.github.com> Date: Tue, 26 Mar 2024 16:59:05 -0400 Subject: [PATCH 3/9] make it shorter and more readable --- .../core/utils/hooks/useSyncExternalStore.ts | 29 +++++-------------- 1 file changed, 8 insertions(+), 21 deletions(-) diff --git a/packages/itwinui-react/src/core/utils/hooks/useSyncExternalStore.ts b/packages/itwinui-react/src/core/utils/hooks/useSyncExternalStore.ts index 78b13ac5225..9285ce1ac75 100644 --- a/packages/itwinui-react/src/core/utils/hooks/useSyncExternalStore.ts +++ b/packages/itwinui-react/src/core/utils/hooks/useSyncExternalStore.ts @@ -24,34 +24,32 @@ export const useSyncExternalStore = // The shim below is adapted from the React source code to make it ESM-compatible. // MIT License: https://github.com/facebook/react/blob/main/LICENSE -/** @see https://github.com/facebook/react/blob/1a6d36b1a3ec43cb5700e28d7315b3aa2822365d/packages/use-sync-external-store/src/useSyncExternalStoreShimServer.js */ function useSESServerShim(_: () => () => void, getSnapshot: () => T): T { return getSnapshot(); } -/** @see https://github.com/facebook/react/blob/1a6d36b1a3ec43cb5700e28d7315b3aa2822365d/packages/use-sync-external-store/src/useSyncExternalStoreShimClient.js */ function useSESClientShim( subscribe: (onSubscribe: () => void) => () => void, getSnapshot: () => T, ): T { const value = getSnapshot(); - const [{ inst }, forceUpdate] = React.useState({ - inst: { value, getSnapshot }, + const [{ instance }, forceUpdate] = React.useState({ + instance: { value, getSnapshot }, }); React.useLayoutEffect(() => { - inst.value = value; - inst.getSnapshot = getSnapshot; + instance.value = value; + instance.getSnapshot = getSnapshot; - if (checkIfSnapshotChanged(inst)) { - forceUpdate({ inst }); + if (!Object.is(value, getSnapshot())) { + forceUpdate({ instance }); } }, [subscribe, value, getSnapshot]); // eslint-disable-line React.useEffect(() => { const synchronize = () => { - if (checkIfSnapshotChanged(inst)) { - forceUpdate({ inst }); + if (!Object.is(instance.value, instance.getSnapshot())) { + forceUpdate({ instance }); } }; synchronize(); @@ -60,14 +58,3 @@ function useSESClientShim( return value; } - -function checkIfSnapshotChanged(inst: { value: T; getSnapshot: () => T }) { - const latestGetSnapshot = inst.getSnapshot; - const prevValue = inst.value; - try { - const nextValue = latestGetSnapshot(); - return !Object.is(prevValue, nextValue); - } catch (error) { - return true; - } -} From aff5411bd6d57ff1d6ed35f4d178067a2fddd63b Mon Sep 17 00:00:00 2001 From: Mayank <9084735+mayank99@users.noreply.github.com> Date: Tue, 26 Mar 2024 17:02:15 -0400 Subject: [PATCH 4/9] move client check out of hook --- packages/itwinui-react/src/core/utils/hooks/useMediaQuery.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/itwinui-react/src/core/utils/hooks/useMediaQuery.ts b/packages/itwinui-react/src/core/utils/hooks/useMediaQuery.ts index 107f50d8aa7..c0ff7413e12 100644 --- a/packages/itwinui-react/src/core/utils/hooks/useMediaQuery.ts +++ b/packages/itwinui-react/src/core/utils/hooks/useMediaQuery.ts @@ -22,7 +22,9 @@ export const useMediaQuery = (queryString: string) => { return useSyncExternalStore( subscribe, - typeof window !== 'undefined' ? getSnapshot : () => undefined, + isClient ? getSnapshot : () => undefined, () => undefined, ); }; + +const isClient = typeof document !== 'undefined'; From 7c47eb65cb52732f2d51e5fb28b93e97a6082363 Mon Sep 17 00:00:00 2001 From: Mayank <9084735+mayank99@users.noreply.github.com> Date: Tue, 26 Mar 2024 17:10:41 -0400 Subject: [PATCH 5/9] fix `window` access during SSR --- .../src/core/utils/hooks/useMediaQuery.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/itwinui-react/src/core/utils/hooks/useMediaQuery.ts b/packages/itwinui-react/src/core/utils/hooks/useMediaQuery.ts index c0ff7413e12..93e8c553793 100644 --- a/packages/itwinui-react/src/core/utils/hooks/useMediaQuery.ts +++ b/packages/itwinui-react/src/core/utils/hooks/useMediaQuery.ts @@ -4,17 +4,18 @@ *--------------------------------------------------------------------------------------------*/ import * as React from 'react'; import { useSyncExternalStore } from './useSyncExternalStore.js'; +import { getWindow } from '../functions/dom.js'; export const useMediaQuery = (queryString: string) => { const [getSnapshot, subscribe] = React.useMemo(() => { - const mediaQueryList = window.matchMedia(queryString); + const mediaQueryList = getWindow()?.matchMedia(queryString); return [ - () => mediaQueryList.matches, + () => mediaQueryList?.matches, (onChange: () => void) => { - mediaQueryList.addEventListener?.('change', onChange); + mediaQueryList?.addEventListener?.('change', onChange); return () => { - mediaQueryList.removeEventListener?.('change', onChange); + mediaQueryList?.removeEventListener?.('change', onChange); }; }, ]; @@ -22,9 +23,7 @@ export const useMediaQuery = (queryString: string) => { return useSyncExternalStore( subscribe, - isClient ? getSnapshot : () => undefined, + typeof document !== 'undefined' ? getSnapshot : () => undefined, () => undefined, ); }; - -const isClient = typeof document !== 'undefined'; From 47a7533fc3a5e3f19e85fb5e8437f49b23ec9a35 Mon Sep 17 00:00:00 2001 From: Mayank <9084735+mayank99@users.noreply.github.com> Date: Tue, 26 Mar 2024 17:16:22 -0400 Subject: [PATCH 6/9] make it even shorter --- .../core/utils/hooks/useSyncExternalStore.ts | 25 +++++++------------ 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/packages/itwinui-react/src/core/utils/hooks/useSyncExternalStore.ts b/packages/itwinui-react/src/core/utils/hooks/useSyncExternalStore.ts index 9285ce1ac75..eccfcb95262 100644 --- a/packages/itwinui-react/src/core/utils/hooks/useSyncExternalStore.ts +++ b/packages/itwinui-react/src/core/utils/hooks/useSyncExternalStore.ts @@ -5,30 +5,23 @@ import * as React from 'react'; const _React = React; // prevent bundlers from stripping the namespace import -const useSyncExternalStoreShim = - typeof document === 'undefined' ? useSESServerShim : useSESClientShim; - /** * Wrapper around `React.useSyncExternalStore` that uses a shim for React 17. - * - * Note: The shim 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 */ export const useSyncExternalStore = _React.useSyncExternalStore || useSyncExternalStoreShim; // ---------------------------------------------------------------------------- -// The shim below is adapted from the React source code to make it ESM-compatible. -// MIT License: https://github.com/facebook/react/blob/main/LICENSE - -function useSESServerShim(_: () => () => void, getSnapshot: () => T): T { - return getSnapshot(); -} - -function useSESClientShim( +/** + * 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 { From 4c84e49888bb8d8dda747622be0b10efc5d6663f Mon Sep 17 00:00:00 2001 From: Mayank <9084735+mayank99@users.noreply.github.com> Date: Tue, 26 Mar 2024 17:19:56 -0400 Subject: [PATCH 7/9] fix jsdom matchMedia --- packages/itwinui-react/src/core/utils/hooks/useMediaQuery.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/itwinui-react/src/core/utils/hooks/useMediaQuery.ts b/packages/itwinui-react/src/core/utils/hooks/useMediaQuery.ts index 93e8c553793..bb295f35bfa 100644 --- a/packages/itwinui-react/src/core/utils/hooks/useMediaQuery.ts +++ b/packages/itwinui-react/src/core/utils/hooks/useMediaQuery.ts @@ -8,7 +8,7 @@ import { getWindow } from '../functions/dom.js'; export const useMediaQuery = (queryString: string) => { const [getSnapshot, subscribe] = React.useMemo(() => { - const mediaQueryList = getWindow()?.matchMedia(queryString); + const mediaQueryList = getWindow()?.matchMedia?.(queryString); return [ () => mediaQueryList?.matches, From 2a67af327b45326d0cbb2a29998ef31fce131907 Mon Sep 17 00:00:00 2001 From: Mayank <9084735+mayank99@users.noreply.github.com> Date: Tue, 26 Mar 2024 17:31:49 -0400 Subject: [PATCH 8/9] split mediaQuery subscribe and getSnapshot --- .../src/core/utils/hooks/useMediaQuery.ts | 32 ++++++++----------- 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/packages/itwinui-react/src/core/utils/hooks/useMediaQuery.ts b/packages/itwinui-react/src/core/utils/hooks/useMediaQuery.ts index bb295f35bfa..8ce85a7dd40 100644 --- a/packages/itwinui-react/src/core/utils/hooks/useMediaQuery.ts +++ b/packages/itwinui-react/src/core/utils/hooks/useMediaQuery.ts @@ -4,26 +4,22 @@ *--------------------------------------------------------------------------------------------*/ import * as React from 'react'; import { useSyncExternalStore } from './useSyncExternalStore.js'; -import { getWindow } from '../functions/dom.js'; export const useMediaQuery = (queryString: string) => { - const [getSnapshot, subscribe] = React.useMemo(() => { - const mediaQueryList = getWindow()?.matchMedia?.(queryString); + const getSnapshot = () => { + return typeof window !== 'undefined' + ? window.matchMedia?.(queryString).matches + : undefined; + }; - return [ - () => mediaQueryList?.matches, - (onChange: () => void) => { - mediaQueryList?.addEventListener?.('change', onChange); - return () => { - mediaQueryList?.removeEventListener?.('change', onChange); - }; - }, - ]; - }, [queryString]); - - return useSyncExternalStore( - subscribe, - typeof document !== 'undefined' ? getSnapshot : () => undefined, - () => undefined, + 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); }; From 4e4743339d8655acacd1026601048cb67ddfcbca Mon Sep 17 00:00:00 2001 From: Mayank <9084735+mayank99@users.noreply.github.com> Date: Tue, 26 Mar 2024 17:34:33 -0400 Subject: [PATCH 9/9] memoize --- packages/itwinui-react/src/core/utils/hooks/useMediaQuery.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/itwinui-react/src/core/utils/hooks/useMediaQuery.ts b/packages/itwinui-react/src/core/utils/hooks/useMediaQuery.ts index 8ce85a7dd40..e08892b2c51 100644 --- a/packages/itwinui-react/src/core/utils/hooks/useMediaQuery.ts +++ b/packages/itwinui-react/src/core/utils/hooks/useMediaQuery.ts @@ -6,11 +6,11 @@ import * as React from 'react'; import { useSyncExternalStore } from './useSyncExternalStore.js'; export const useMediaQuery = (queryString: string) => { - const getSnapshot = () => { + const getSnapshot = React.useCallback(() => { return typeof window !== 'undefined' ? window.matchMedia?.(queryString).matches : undefined; - }; + }, [queryString]); const subscribe = React.useCallback( (onChange: () => void) => {