@sanity/ui 2.6.7-canary.0 → 2.6.8-canary.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/dist/index.d.mts +6 -42
  2. package/dist/index.d.ts +6 -42
  3. package/dist/index.esm.js +158 -87
  4. package/dist/index.esm.js.map +1 -1
  5. package/dist/index.js +157 -86
  6. package/dist/index.js.map +1 -1
  7. package/dist/index.mjs +158 -87
  8. package/dist/index.mjs.map +1 -1
  9. package/package.json +1 -1
  10. package/src/core/components/breadcrumbs/breadcrumbs.tsx +6 -15
  11. package/src/core/components/dialog/dialog.tsx +21 -12
  12. package/src/core/components/menu/menu.tsx +18 -11
  13. package/src/core/components/menu/menuButton.tsx +1 -1
  14. package/src/core/components/menu/menuContext.ts +1 -1
  15. package/src/core/components/menu/useMenuController.ts +17 -11
  16. package/src/core/components/toast/styles.ts +1 -2
  17. package/src/core/components/toast/useToast.ts +0 -1
  18. package/src/core/components/tree/tree.tsx +0 -1
  19. package/src/core/components/tree/treeItem.tsx +0 -1
  20. package/src/core/helpers/focus.ts +0 -1
  21. package/src/core/helpers/scroll.ts +0 -1
  22. package/src/core/hooks/_internal/index.ts +1 -0
  23. package/src/core/hooks/_internal/useUnique.ts +34 -0
  24. package/src/core/hooks/index.ts +2 -3
  25. package/src/core/hooks/useClickOutside.ts +72 -38
  26. package/src/core/hooks/useElementSize.ts +0 -1
  27. package/src/core/hooks/useMediaIndex/useMediaIndex.ts +10 -2
  28. package/src/core/hooks/usePrefersDark.ts +55 -10
  29. package/src/core/hooks/usePrefersReducedMotion.ts +56 -10
  30. package/src/core/primitives/popover/__workshop__/AlignedStory.tsx +7 -9
  31. package/src/core/primitives/tooltip/__workshop__/customPortal.tsx +6 -8
  32. package/src/core/primitives/tooltip/tooltipDelayGroup/tooltipDelayGroupProvider.tsx +0 -1
  33. package/src/core/utils/layer/layerProvider.tsx +0 -1
  34. package/src/core/utils/portal/__workshop__/named.tsx +8 -10
  35. package/src/core/utils/portal/portalProvider.tsx +13 -7
  36. package/src/core/hooks/useMatchMedia.ts +0 -46
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sanity/ui",
3
- "version": "2.6.7-canary.0",
3
+ "version": "2.6.8-canary.0",
4
4
  "keywords": [
5
5
  "sanity",
6
6
  "ui",
@@ -1,13 +1,4 @@
1
- import {
2
- Children,
3
- forwardRef,
4
- Fragment,
5
- isValidElement,
6
- useCallback,
7
- useMemo,
8
- useRef,
9
- useState,
10
- } from 'react'
1
+ import {Children, forwardRef, Fragment, isValidElement, useCallback, useMemo, useState} from 'react'
11
2
  import {useArrayProp, useClickOutside} from '../../hooks'
12
3
  import {Box, Popover, Stack, Text} from '../../primitives'
13
4
  import {ExpandButton, Root} from './breadcrumbs.styles'
@@ -31,13 +22,13 @@ export const Breadcrumbs = forwardRef(function Breadcrumbs(
31
22
  const {children, maxLength, separator, space: spaceRaw = 2, ...restProps} = props
32
23
  const space = useArrayProp(spaceRaw)
33
24
  const [open, setOpen] = useState(false)
34
- const expandElementRef = useRef<HTMLButtonElement | null>(null)
35
- const popoverElementRef = useRef<HTMLDivElement | null>(null)
25
+ const [expandElement, setExpandElement] = useState<HTMLButtonElement | null>(null)
26
+ const [popoverElement, setPopoverElement] = useState<HTMLDivElement | null>(null)
36
27
 
37
28
  const collapse = useCallback(() => setOpen(false), [])
38
29
  const expand = useCallback(() => setOpen(true), [])
39
30
 
40
- useClickOutside(collapse, () => [expandElementRef.current, popoverElementRef.current])
31
+ useClickOutside(collapse, [expandElement, popoverElement])
41
32
 
42
33
  const rawItems = useMemo(() => Children.toArray(children).filter(isValidElement), [children])
43
34
 
@@ -61,14 +52,14 @@ export const Breadcrumbs = forwardRef(function Breadcrumbs(
61
52
  open={open}
62
53
  placement="top"
63
54
  portal
64
- ref={popoverElementRef}
55
+ ref={setPopoverElement}
65
56
  >
66
57
  <ExpandButton
67
58
  fontSize={1}
68
59
  mode="bleed"
69
60
  onClick={open ? collapse : expand}
70
61
  padding={1}
71
- ref={expandElementRef}
62
+ ref={setExpandElement}
72
63
  selected={open}
73
64
  text="…"
74
65
  />
@@ -1,6 +1,6 @@
1
1
  import {CloseIcon} from '@sanity/icons'
2
2
  import {ThemeColorSchemeKey} from '@sanity/ui/theme'
3
- import {forwardRef, useCallback, useEffect, useImperativeHandle, useRef} from 'react'
3
+ import {forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react'
4
4
  import {styled} from 'styled-components'
5
5
  import {
6
6
  containsOrEqualsElement,
@@ -170,6 +170,7 @@ const DialogCard = forwardRef(function DialogCard(
170
170
  const shadow = useArrayProp(shadowProp)
171
171
  const width = useArrayProp(widthProp)
172
172
  const ref = useRef<HTMLDivElement | null>(null)
173
+ const [rootElement, setRootElement] = useState<HTMLDivElement | null>(null)
173
174
  const contentRef = useRef<HTMLDivElement | null>(null)
174
175
  const layer = useLayer()
175
176
  const {isTopLayer} = layer
@@ -215,24 +216,32 @@ const DialogCard = forwardRef(function DialogCard(
215
216
  )
216
217
 
217
218
  useClickOutside(
218
- (event: MouseEvent | TouchEvent) => {
219
- if (!isTopLayer || !onClickOutside) return
219
+ useCallback(
220
+ (event: MouseEvent) => {
221
+ if (!isTopLayer || !onClickOutside) return
220
222
 
221
- const target = event.target as Node | null
223
+ const target = event.target as Node | null
222
224
 
223
- if (target && !isTargetWithinScope(boundaryElement, portalElement, target)) {
224
- // Ignore clicks outside of the scope
225
- return
226
- }
225
+ if (target && !isTargetWithinScope(boundaryElement, portalElement, target)) {
226
+ // Ignore clicks outside of the scope
227
+ return
228
+ }
227
229
 
228
- onClickOutside()
229
- },
230
- () => [ref.current],
230
+ onClickOutside()
231
+ },
232
+ [boundaryElement, isTopLayer, onClickOutside, portalElement],
233
+ ),
234
+ [rootElement],
231
235
  )
232
236
 
237
+ const setRef = useCallback((el: HTMLDivElement | null) => {
238
+ setRootElement(el)
239
+ ref.current = el
240
+ }, [])
241
+
233
242
  return (
234
243
  <DialogContainer data-ui="DialogCard" width={width}>
235
- <DialogCardRoot radius={radius} ref={ref} scheme={scheme} shadow={shadow}>
244
+ <DialogCardRoot radius={radius} ref={setRef} scheme={scheme} shadow={shadow}>
236
245
  <DialogLayout direction="column">
237
246
  {showHeader && (
238
247
  <DialogHeader>
@@ -19,7 +19,7 @@ export interface MenuProps extends ResponsivePaddingProps {
19
19
  * @deprecated Use `shouldFocus="last"` instead.
20
20
  */
21
21
  focusLast?: boolean
22
- onClickOutside?: (event: MouseEvent | TouchEvent) => void
22
+ onClickOutside?: (event: MouseEvent) => void
23
23
  onEscape?: () => void
24
24
  onItemClick?: () => void
25
25
  onItemSelect?: (index: number) => void
@@ -75,18 +75,16 @@ export const Menu = forwardRef(function Menu(
75
75
  handleItemMouseLeave,
76
76
  handleKeyDown,
77
77
  mount,
78
- } = useMenuController({onKeyDown, originElement, shouldFocus, rootElementRef: ref})
78
+ rootElement,
79
+ setRootElement,
80
+ } = useMenuController({onKeyDown, originElement, shouldFocus})
79
81
 
80
82
  const handleRefChange = useCallback(
81
83
  (el: HTMLDivElement | null) => {
84
+ setRootElement(el)
82
85
  ref.current = el
83
-
84
- // Register root element (for nested menus)
85
- if (ref.current && registerElement) {
86
- registerElement(ref.current)
87
- }
88
86
  },
89
- [registerElement],
87
+ [setRootElement],
90
88
  )
91
89
 
92
90
  // Trigger `onItemSelect` when active index changes
@@ -96,8 +94,11 @@ export const Menu = forwardRef(function Menu(
96
94
 
97
95
  // Close menu when clicking outside
98
96
  useClickOutside(
99
- (event) => isTopLayer && onClickOutside && onClickOutside(event),
100
- () => [ref.current],
97
+ useCallback(
98
+ (event) => isTopLayer && onClickOutside && onClickOutside(event),
99
+ [isTopLayer, onClickOutside],
100
+ ),
101
+ [rootElement],
101
102
  )
102
103
 
103
104
  // Close menu when pressing Escape
@@ -115,7 +116,13 @@ export const Menu = forwardRef(function Menu(
115
116
  ),
116
117
  )
117
118
 
118
- // @TODO split out into separate contexts
119
+ // Register root element (for nested menus)
120
+ useEffect(() => {
121
+ if (!rootElement || !registerElement) return
122
+
123
+ return registerElement(rootElement)
124
+ }, [registerElement, rootElement])
125
+
119
126
  const value: MenuContextValue = useMemo(
120
127
  () => ({
121
128
  version: 0.0,
@@ -140,7 +140,7 @@ export const MenuButton = forwardRef(function MenuButton(
140
140
  }, [])
141
141
 
142
142
  const handleMenuClickOutside = useCallback(
143
- (event: MouseEvent | TouchEvent) => {
143
+ (event: MouseEvent) => {
144
144
  const target = event.target
145
145
 
146
146
  if (!(target instanceof Node)) {
@@ -5,7 +5,7 @@ export interface MenuContextValue {
5
5
  activeElement: HTMLElement | null
6
6
  activeIndex: number
7
7
  mount: (element: HTMLElement | null, selected?: boolean) => () => void
8
- onClickOutside?: (event: MouseEvent | TouchEvent) => void
8
+ onClickOutside?: (event: MouseEvent) => void
9
9
  onEscape?: () => void
10
10
  onItemClick?: () => void
11
11
  onItemMouseEnter?: (event: React.MouseEvent<HTMLElement>) => void
@@ -1,4 +1,4 @@
1
- import {useCallback, useEffect, useMemo, useRef, useState} from 'react'
1
+ import {useCallback, useEffect, useRef, useState} from 'react'
2
2
  import {_getFocusableElements, _sortElements} from './helpers'
3
3
 
4
4
  /**
@@ -11,6 +11,8 @@ export interface MenuController {
11
11
  handleItemMouseLeave: () => void
12
12
  handleKeyDown: (event: React.KeyboardEvent<HTMLDivElement>) => void
13
13
  mount: (element: HTMLElement | null, selected?: boolean) => () => void
14
+ rootElement: HTMLDivElement | null
15
+ setRootElement: (el: HTMLDivElement | null) => void
14
16
  }
15
17
 
16
18
  /**
@@ -22,14 +24,14 @@ export function useMenuController(props: {
22
24
  onKeyDown?: React.KeyboardEventHandler
23
25
  originElement?: HTMLElement | null
24
26
  shouldFocus: 'first' | 'last' | null
25
- rootElementRef: React.MutableRefObject<HTMLDivElement | null>
26
27
  }): MenuController {
27
- const {onKeyDown, originElement, shouldFocus, rootElementRef} = props
28
+ const {onKeyDown, originElement, shouldFocus} = props
28
29
  const elementsRef = useRef<HTMLElement[]>([])
30
+ const [rootElement, setRootElement] = useState<HTMLDivElement | null>(null)
29
31
  const [activeIndex, _setActiveIndex] = useState(-1)
30
32
  const activeIndexRef = useRef(activeIndex)
31
- const activeElement = useMemo(() => elementsRef.current[activeIndex] || null, [activeIndex])
32
- const mounted = Boolean(rootElementRef.current)
33
+ const activeElement = elementsRef.current[activeIndex] || null
34
+ const mounted = Boolean(rootElement)
33
35
 
34
36
  const setActiveIndex = useCallback((nextActiveIndex: number) => {
35
37
  _setActiveIndex(nextActiveIndex)
@@ -42,7 +44,7 @@ export function useMenuController(props: {
42
44
 
43
45
  if (elementsRef.current.indexOf(element) === -1) {
44
46
  elementsRef.current.push(element)
45
- _sortElements(rootElementRef.current, elementsRef.current)
47
+ _sortElements(rootElement, elementsRef.current)
46
48
  }
47
49
 
48
50
  if (selected) {
@@ -59,7 +61,7 @@ export function useMenuController(props: {
59
61
  }
60
62
  }
61
63
  },
62
- [rootElementRef, setActiveIndex],
64
+ [rootElement, setActiveIndex],
63
65
  )
64
66
 
65
67
  const handleKeyDown = useCallback(
@@ -177,15 +179,17 @@ export function useMenuController(props: {
177
179
  // which would be incorrect when the user hovers over a gap
178
180
  // between two menu items or a menu divider.
179
181
  setActiveIndex(-2)
180
- rootElementRef.current?.focus()
181
- }, [rootElementRef, setActiveIndex])
182
+ rootElement?.focus()
183
+ }, [setActiveIndex, rootElement])
182
184
 
183
185
  // Set focus on the currently active element
184
186
  useEffect(() => {
185
187
  if (!mounted) return
186
188
 
187
189
  const rafId = window.requestAnimationFrame(() => {
188
- if (activeIndex === -1) {
190
+ const _activeIndex = activeIndexRef.current
191
+
192
+ if (_activeIndex === -1) {
189
193
  if (shouldFocus === 'first') {
190
194
  const focusableElements = _getFocusableElements(elementsRef.current)
191
195
  const el = focusableElements[0]
@@ -213,7 +217,7 @@ export function useMenuController(props: {
213
217
  return
214
218
  }
215
219
 
216
- const element = elementsRef.current[activeIndex] || null
220
+ const element = elementsRef.current[_activeIndex] || null
217
221
 
218
222
  element?.focus()
219
223
  })
@@ -230,5 +234,7 @@ export function useMenuController(props: {
230
234
  handleItemMouseLeave,
231
235
  handleKeyDown,
232
236
  mount,
237
+ rootElement,
238
+ setRootElement,
233
239
  }
234
240
  }
@@ -14,12 +14,11 @@ const loadingAnimation = keyframes`
14
14
  }
15
15
  100% {
16
16
  width: 100%;
17
- }
17
+ }
18
18
  `
19
19
 
20
20
  const LOADING_BAR_HEIGHT = 2
21
21
 
22
- // @TODO get rid of $duration modifier, set data attribute instead and use stable selector
23
22
  export function rootStyles(
24
23
  props: {$duration?: number; tone: ThemeColorStateToneKey} & ThemeProps,
25
24
  ): ReturnType<typeof css> {
@@ -13,7 +13,6 @@ export function useToast(): ToastContextValue {
13
13
  throw new Error('useToast(): missing context value')
14
14
  }
15
15
 
16
- // @TODO context and hooks doesn't really work like this, there will never be a mismatch between the provider and the consumer, we can remove these version specifiers
17
16
  // NOTE: This check is for future-compatiblity
18
17
  // - If the value is not an object, it’s not compatible with the current version
19
18
  // - If the value is an object, but doesn’t have `version: 0.0`, it’s not compatible with the current version
@@ -84,7 +84,6 @@ export const Tree = memo(
84
84
  })
85
85
  }, [])
86
86
 
87
- // @TODO split out into separate contexts
88
87
  const contextValue: TreeContextValue = useMemo(
89
88
  () => ({
90
89
  version: 0.0,
@@ -76,7 +76,6 @@ export const TreeItem = memo(function TreeItem(
76
76
  const focused = tree.focusedElement === rootRef.current
77
77
  const expanded = itemState?.expanded === undefined ? expandedProp : itemState?.expanded || false
78
78
  const tabIndex = tree.focusedElement && tree.focusedElement === rootRef.current ? 0 : -1
79
- // @TODO split out into separate contexts
80
79
  const contextValue = useMemo(
81
80
  () => ({...tree, level: tree.level + 1, path: itemPath}),
82
81
  [itemPath, tree],
@@ -15,7 +15,6 @@ import {
15
15
  * @internal
16
16
  */
17
17
  export function _hasFocus(element: HTMLElement): boolean {
18
- // @TODO verify this is not called during render
19
18
  return Boolean(document.activeElement) && element.contains(document.activeElement)
20
19
  }
21
20
 
@@ -4,7 +4,6 @@
4
4
  export function _isScrollable(el: Node): boolean {
5
5
  if (!(el instanceof Element)) return false
6
6
 
7
- // @TODO check if this is called during render
8
7
  const style = window.getComputedStyle(el)
9
8
 
10
9
  return (
@@ -0,0 +1 @@
1
+ export * from './useUnique'
@@ -0,0 +1,34 @@
1
+ import {useRef} from 'react'
2
+
3
+ /**
4
+ * This is a React hook to make sure that a record identity is the same on every render. Uses strict
5
+ * equality comparison (eg by identity), and only goes one level deep.
6
+ *
7
+ * @internal
8
+ */
9
+ type Comparable = Record<string | number | symbol, unknown> | undefined | null
10
+
11
+ export function useUnique<ValueType extends Comparable = Comparable>(value: ValueType): ValueType {
12
+ const valueRef = useRef<ValueType>(value)
13
+
14
+ if (!_isEqual(valueRef.current, value)) {
15
+ valueRef.current = value
16
+ }
17
+
18
+ return valueRef.current
19
+ }
20
+
21
+ function _isEqual(objA: Comparable, objB: Comparable): boolean {
22
+ if (!objA || !objB) {
23
+ return objA === objB
24
+ }
25
+
26
+ const keysA = Object.keys(objA)
27
+ const keysB = Object.keys(objB)
28
+
29
+ if (keysA.length !== keysB.length) {
30
+ return false
31
+ }
32
+
33
+ return keysA.every((key) => objA[key] === objB[key])
34
+ }
@@ -1,11 +1,10 @@
1
1
  export * from './useArrayProp'
2
2
  export * from './useClickOutside'
3
- export * from './useCustomValidity'
4
3
  export * from './useElementRect'
5
4
  export * from './useElementSize'
6
- export * from './useForwardedRef'
7
5
  export * from './useGlobalKeyDown'
8
- export * from './useMatchMedia'
9
6
  export * from './useMediaIndex'
10
7
  export * from './usePrefersDark'
11
8
  export * from './usePrefersReducedMotion'
9
+ export * from './useForwardedRef'
10
+ export * from './useCustomValidity'
@@ -1,65 +1,99 @@
1
- import {useEffect} from 'react'
2
- import {useEffectEvent} from 'use-effect-event'
1
+ import {useEffect, useRef, useState} from 'react'
3
2
  import {EMPTY_ARRAY} from '../constants'
4
3
 
5
4
  /**
6
5
  * @public
7
6
  */
8
- export type ClickOutsideListener = (event: MouseEvent | TouchEvent) => void
7
+ export type ClickOutsideListener = (event: MouseEvent) => void
8
+
9
+ function _getElements(
10
+ element: HTMLElement | null,
11
+ elementsArg: Array<HTMLElement | HTMLElement[] | null>,
12
+ ): HTMLElement[] {
13
+ const ret = [element]
14
+
15
+ for (const el of elementsArg) {
16
+ if (Array.isArray(el)) {
17
+ ret.push(...el)
18
+ } else {
19
+ ret.push(el)
20
+ }
21
+ }
22
+
23
+ return ret.filter(Boolean) as HTMLElement[]
24
+ }
9
25
 
10
26
  /**
11
- * Use the callback version of `elementsArg` if you're using `useRef` to handle elements:
12
- * ```tsx
13
- * useClickOutside(
14
- * () => {},
15
- * () => [ref.current],
16
- * )
17
- *
18
27
  * @public
19
28
  */
20
29
  export function useClickOutside(
21
30
  listener: ClickOutsideListener,
22
- elementsArg:
23
- | Array<HTMLElement | HTMLElement[] | null>
24
- | (() => Array<HTMLElement | HTMLElement[] | null>) = EMPTY_ARRAY,
31
+ elementsArg: Array<HTMLElement | HTMLElement[] | null> = EMPTY_ARRAY,
25
32
  boundaryElement?: HTMLElement | null,
26
- ): void {
27
- /**
28
- * The `useEffectEvent` hook allow us to always see the latest value of `listener`, `elementsArg` and `boundaryElement` without needing to
29
- * juggle `useState`, `useRef` and `useState` to make sure the `mousedown` event listener isn't constantly being added and removed.
30
- */
31
- const eventHandler = useEffectEvent((evt: MouseEvent | TouchEvent) => {
32
- const target = evt.target
33
-
34
- if (!(target instanceof Node)) {
35
- return
36
- }
33
+ ): (el: HTMLElement | null) => void {
34
+ const [element, setElement] = useState<HTMLElement | null>(null)
35
+ const [elements, setElements] = useState(() => _getElements(element, elementsArg))
36
+ const elementsRef = useRef(elements)
37
+
38
+ useEffect(() => {
39
+ const prevElements = elementsRef.current
40
+ const nextElements = _getElements(element, elementsArg)
41
+
42
+ if (prevElements.length !== nextElements.length) {
43
+ setElements(nextElements)
44
+ elementsRef.current = nextElements
37
45
 
38
- if (boundaryElement && !boundaryElement.contains(target)) {
39
46
  return
40
47
  }
41
48
 
42
- const resolvedElements = Array.isArray(elementsArg) ? elementsArg : elementsArg()
43
- const elements = resolvedElements.flat()
44
-
45
- for (const el of elements) {
46
- if (!el) continue
49
+ for (const el of prevElements) {
50
+ if (!nextElements.includes(el)) {
51
+ setElements(nextElements)
52
+ elementsRef.current = nextElements
47
53
 
48
- if (target === el || el.contains(target)) {
49
54
  return
50
55
  }
51
56
  }
52
57
 
53
- listener(evt)
54
- })
58
+ for (const el of nextElements) {
59
+ if (!prevElements.includes(el)) {
60
+ setElements(nextElements)
61
+ elementsRef.current = nextElements
62
+
63
+ return
64
+ }
65
+ }
66
+ }, [element, elementsArg])
55
67
 
56
68
  useEffect(() => {
57
- document.addEventListener('mousedown', eventHandler)
58
- document.addEventListener('touchstart', eventHandler)
69
+ if (!listener) return undefined
70
+
71
+ const handleWindowMouseDown = (evt: MouseEvent) => {
72
+ const target = evt.target
73
+
74
+ if (!(target instanceof Node)) {
75
+ return
76
+ }
77
+
78
+ if (boundaryElement && !boundaryElement.contains(target)) {
79
+ return
80
+ }
81
+
82
+ for (const el of elements) {
83
+ if (target === el || el.contains(target)) {
84
+ return
85
+ }
86
+ }
87
+
88
+ listener(evt)
89
+ }
90
+
91
+ window.addEventListener('mousedown', handleWindowMouseDown)
59
92
 
60
93
  return () => {
61
- document.removeEventListener('mousedown', eventHandler)
62
- document.removeEventListener('touchstart', eventHandler)
94
+ window.removeEventListener('mousedown', handleWindowMouseDown)
63
95
  }
64
- }, [eventHandler])
96
+ }, [boundaryElement, listener, elements])
97
+
98
+ return setElement
65
99
  }
@@ -6,7 +6,6 @@ import {ElementSize, _elementSizeObserver} from '../observers/elementSizeObserve
6
6
  * @beta
7
7
  */
8
8
  export function useElementSize(element: HTMLElement | null): ElementSize | null {
9
- // @TODO we can probably use something in framer-motion or @floating-ui instead of rolling our own
10
9
  const [size, setSize] = useState<ElementSize | null>(null)
11
10
 
12
11
  useEffect(() => {
@@ -1,4 +1,4 @@
1
- import {useMemo, useSyncExternalStore} from 'react'
1
+ import {useSyncExternalStore} from 'react'
2
2
  import {useTheme_v2} from '../../theme'
3
3
 
4
4
  /**
@@ -9,6 +9,8 @@ export interface _MediaStore {
9
9
  getSnapshot: () => number
10
10
  }
11
11
 
12
+ const MEDIA_STORE_CACHE = new WeakMap<number[], _MediaStore>()
13
+
12
14
  type MediaQueryMinWidth = `(min-width: ${number}px)`
13
15
  type MediaQueryMaxWidth = `(max-width: ${number}px)`
14
16
  type MediaQueryMinMaxWidth = `${MediaQueryMinWidth} and ${MediaQueryMaxWidth}`
@@ -95,7 +97,13 @@ function getServerSnapshot() {
95
97
  */
96
98
  export function useMediaIndex(): number {
97
99
  const {media} = useTheme_v2()
98
- const store = useMemo(() => _createMediaStore(media), [media])
100
+
101
+ let store = MEDIA_STORE_CACHE.get(media)
102
+
103
+ if (!store) {
104
+ store = _createMediaStore(media)
105
+ MEDIA_STORE_CACHE.set(media, store)
106
+ }
99
107
 
100
108
  return useSyncExternalStore(store.subscribe, store.getSnapshot, getServerSnapshot)
101
109
  }
@@ -1,16 +1,61 @@
1
- import {useMatchMedia} from './useMatchMedia'
1
+ import {useSyncExternalStore} from 'react'
2
+
3
+ let MEDIA_QUERY_CACHE: MediaQueryList | undefined
2
4
 
3
5
  /**
4
- * Returns true if a dark color scheme is preferred, false if a light color scheme is preferred or the preference is not known.
5
- *
6
- * @param getServerSnapshot - Only called during server-side rendering, and hydration if using hydrateRoot. Since the server environment doesn't have access to the DOM, we can't determine the current value of the media query and we assume `(prefers-color-scheme: light)` since it's the most common scheme (https://react.dev/reference/react/useSyncExternalStore#adding-support-for-server-rendering)
7
- *
8
- * If you persist the detected preference in a cookie or a header then you may implement your own server snapshot to read it.
9
- * Chrome supports reading the `prefers-color-scheme` media query from a header if the server response: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-CH-Prefers-Color-Scheme
10
- * @example https://gist.github.com/stipsan/13c0cccf8dfc34f4b44bb1b984baf7df
6
+ * Lazy init the matchMedia instance
7
+ */
8
+ function getMatchMedia(): MediaQueryList {
9
+ if (!MEDIA_QUERY_CACHE) {
10
+ // As this function is only called during `subscribe` and `getSnapshot`, we can assume that the
11
+ // the `window` global is available and we're in a browser environment
12
+ MEDIA_QUERY_CACHE = window.matchMedia('(prefers-color-scheme: dark)')
13
+ }
14
+
15
+ return MEDIA_QUERY_CACHE
16
+ }
17
+
18
+ /**
19
+ * As the query is the same for all instances of this hook, we can cache the matchMedia instance
20
+ * and have cheap `change` event listeners, while getSnapshot always reads from the same
21
+ * matchMedia instance and we don't get any tearing.
22
+ * Tearing in this context means the bad edge case in React concurrent render mdoe
23
+ * where you sometimes would end up with some components doing render while seeing `usePrefersDark() === true` while others would see `usePrefersDark() === false`
24
+ * during the same render.
25
+ * By using `useSyncExternalStore` every component only sees the same value during the same render, and always re-render when it changes no matter
26
+ * what React.memo boundaries there might be between the layers..
27
+ */
28
+ function subscribe(onStoreChange: () => void): () => void {
29
+ const matchMedia = getMatchMedia()
30
+
31
+ matchMedia.addEventListener('change', onStoreChange)
32
+
33
+ return () => matchMedia.removeEventListener('change', onStoreChange)
34
+ }
35
+
36
+ /**
37
+ * Only called client-side, when using createRoot, or after hydration is complete when using hydrateRoot.
38
+ * It's important that this function does not create new objects or arrays when called:
39
+ * https://beta.reactjs.org/apis/react/useSyncExternalStore#im-getting-an-error-the-result-of-getsnapshot-should-be-cached
40
+ */
41
+ function getSnapshot() {
42
+ return getMatchMedia().matches
43
+ }
44
+
45
+ /**
46
+ * Only called during server-side rendering, and hydration if using hydrateRoot
47
+ * Since the server environment doesn't have access to the DOM, we can't determine the current value of the media query
48
+ * and we assume `(prefers-color-scheme: light)` since it's the most common scheme
11
49
  *
50
+ * @link https://beta.reactjs.org/apis/react/useSyncExternalStore#adding-support-for-server-rendering
51
+ */
52
+ function getServerSnapshot() {
53
+ return false
54
+ }
55
+
56
+ /**
12
57
  * @public
13
58
  */
14
- export function usePrefersDark(getServerSnapshot = () => false): boolean {
15
- return useMatchMedia('(prefers-color-scheme: dark)', getServerSnapshot)
59
+ export function usePrefersDark(): boolean {
60
+ return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
16
61
  }