@sanity/ui 1.1.0-beta.2 → 1.2.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 (51) hide show
  1. package/LICENSE +1 -1
  2. package/dist/index.cjs.mjs +120 -0
  3. package/dist/index.d.ts +26 -4
  4. package/dist/{index.cjs → index.esm.js} +1987 -1946
  5. package/dist/index.esm.js.map +1 -0
  6. package/dist/index.js +2114 -1819
  7. package/dist/index.js.map +1 -1
  8. package/package.json +47 -44
  9. package/src/components/dialog/__workshop__/activate.tsx +134 -0
  10. package/src/components/dialog/__workshop__/index.ts +3 -0
  11. package/src/components/dialog/__workshop__/nested.tsx +14 -3
  12. package/src/components/dialog/__workshop__/panes.tsx +86 -0
  13. package/src/components/dialog/__workshop__/wrapped.tsx +78 -0
  14. package/src/components/dialog/dialog.tsx +110 -25
  15. package/src/components/toast/toastProvider.tsx +55 -52
  16. package/src/constants.ts +2 -2
  17. package/src/helpers/element.ts +7 -0
  18. package/src/hooks/index.ts +1 -0
  19. package/src/hooks/useArrayProp.ts +2 -1
  20. package/src/hooks/useMediaIndex/useMediaIndex.test.tsx +31 -0
  21. package/src/hooks/useMediaIndex/useMediaIndex.ts +98 -6
  22. package/src/hooks/useMounted.ts +14 -0
  23. package/src/hooks/usePrefersDark.hydration.test.tsx +56 -0
  24. package/src/hooks/usePrefersDark.test.tsx +18 -0
  25. package/src/hooks/usePrefersDark.ts +51 -18
  26. package/src/hooks/usePrefersReducedMotion.hydration.test.tsx +56 -0
  27. package/src/hooks/usePrefersReducedMotion.test.tsx +18 -0
  28. package/src/hooks/usePrefersReducedMotion.ts +62 -0
  29. package/src/observers/resizeObserver.ts +1 -1
  30. package/src/primitives/code/styles.ts +6 -0
  31. package/src/primitives/heading/styles.ts +6 -0
  32. package/src/primitives/label/styles.ts +6 -0
  33. package/src/primitives/popover/__workshop__/OpenOnMountStory.tsx +11 -0
  34. package/src/primitives/popover/__workshop__/index.ts +5 -0
  35. package/src/primitives/text/styles.ts +6 -0
  36. package/src/utils/layer/__workshop__/_debug.tsx +9 -3
  37. package/src/utils/layer/__workshop__/index.ts +3 -3
  38. package/src/utils/layer/__workshop__/multipleRoots.tsx +2 -1
  39. package/src/utils/layer/__workshop__/{plain.tsx → nested.tsx} +14 -4
  40. package/src/utils/layer/getLayerContext.test.ts +30 -0
  41. package/src/utils/layer/getLayerContext.ts +21 -0
  42. package/src/utils/layer/layer.test.tsx +1 -0
  43. package/src/utils/layer/layer.tsx +49 -7
  44. package/src/utils/layer/layerContext.ts +2 -2
  45. package/src/utils/layer/layerProvider.tsx +73 -12
  46. package/src/utils/layer/types.ts +3 -4
  47. package/src/utils/layer/useLayer.ts +9 -8
  48. package/src/utils/portal/portalContext.ts +1 -1
  49. package/src/utils/portal/portalProvider.tsx +1 -1
  50. package/dist/index.cjs.map +0 -1
  51. package/src/hooks/useMediaIndex/lib/media.ts +0 -88
@@ -1,14 +1,19 @@
1
1
  import {CloseIcon} from '@sanity/icons'
2
2
  import {forwardRef, useCallback, useEffect, useRef, useState} from 'react'
3
3
  import styled from 'styled-components'
4
- import {focusFirstDescendant, focusLastDescendant} from '../../helpers'
4
+ import {
5
+ containsOrEqualsElement,
6
+ focusFirstDescendant,
7
+ focusLastDescendant,
8
+ isHTMLElement,
9
+ } from '../../helpers'
5
10
  import {useArrayProp, useClickOutside, useForwardedRef, useGlobalKeyDown} from '../../hooks'
6
11
  import {Box, Button, Card, Container, Flex, Text} from '../../primitives'
7
12
  import {ResponsivePaddingProps, ResponsiveWidthProps} from '../../primitives/types'
8
13
  import {responsivePaddingStyle, ResponsivePaddingStyleProps} from '../../styles/internal'
9
14
  import {ThemeColorSchemeKey, useTheme} from '../../theme'
10
15
  import {DialogPosition} from '../../types'
11
- import {Layer, Portal, useLayer} from '../../utils'
16
+ import {Layer, LayerProps, Portal, useBoundaryElement, useLayer, usePortal} from '../../utils'
12
17
  import {
13
18
  dialogStyle,
14
19
  responsiveDialogPositionStyle,
@@ -34,6 +39,8 @@ export interface DialogProps extends ResponsivePaddingProps, ResponsiveWidthProp
34
39
  footer?: React.ReactNode
35
40
  header?: React.ReactNode
36
41
  id: string
42
+ /** A callback that fires when the dialog becomes the top layer when it was not the top layer before. */
43
+ onActivate?: LayerProps['onActivate']
37
44
  onClickOutside?: () => void
38
45
  onClose?: () => void
39
46
  portal?: string
@@ -58,11 +65,25 @@ interface DialogCardProps extends ResponsiveWidthProps {
58
65
  id: string
59
66
  onClickOutside?: () => void
60
67
  onClose?: () => void
68
+ portal?: string
61
69
  radius: number | number[]
62
70
  scheme?: ThemeColorSchemeKey
63
71
  shadow: number | number[]
64
72
  }
65
73
 
74
+ function isTargetWithinScope(
75
+ boundaryElement: HTMLElement | null,
76
+ portalElement: HTMLElement | null,
77
+ target: Node
78
+ ): boolean {
79
+ if (!boundaryElement || !portalElement) return true
80
+
81
+ return (
82
+ containsOrEqualsElement(boundaryElement, target) ||
83
+ containsOrEqualsElement(portalElement, target)
84
+ )
85
+ }
86
+
66
87
  const Root = styled(Layer)<ResponsiveDialogPositionStyleProps & ResponsivePaddingStyleProps>(
67
88
  responsivePaddingStyle,
68
89
  dialogStyle,
@@ -138,11 +159,15 @@ const DialogCard = forwardRef(function DialogCard(
138
159
  id,
139
160
  onClickOutside,
140
161
  onClose,
162
+ portal: portalProp,
141
163
  radius: radiusProp,
142
164
  scheme,
143
165
  shadow: shadowProp,
144
166
  width: widthProp,
145
167
  } = props
168
+ const portal = usePortal()
169
+ const portalElement = portalProp ? portal.elements?.[portalProp] || null : portal.element
170
+ const boundaryElement = useBoundaryElement().element
146
171
  const radius = useArrayProp(radiusProp)
147
172
  const shadow = useArrayProp(shadowProp)
148
173
  const width = useArrayProp(widthProp)
@@ -158,7 +183,7 @@ const DialogCard = forwardRef(function DialogCard(
158
183
  useEffect(() => {
159
184
  if (!autoFocus) return
160
185
 
161
- // On mount: focus the first interactive element in the contents
186
+ // On mount: focus the first focusable element
162
187
  if (forwardedRef.current) {
163
188
  focusFirstDescendant(forwardedRef.current)
164
189
  }
@@ -169,22 +194,39 @@ const DialogCard = forwardRef(function DialogCard(
169
194
  (event: KeyboardEvent) => {
170
195
  if (!isTopLayer || !onClose) return
171
196
 
197
+ const target = document.activeElement
198
+
199
+ if (target && !isTargetWithinScope(boundaryElement, portalElement, target)) {
200
+ // Ignore key presses when the focused element is outside of scope
201
+ return
202
+ }
203
+
172
204
  if (event.key === 'Escape') {
173
205
  event.preventDefault()
174
206
  event.stopPropagation()
175
207
  onClose()
176
208
  }
177
209
  },
178
- [isTopLayer, onClose]
210
+ [boundaryElement, isTopLayer, onClose, portalElement]
179
211
  )
180
212
  )
181
213
 
182
214
  useClickOutside(
183
- useCallback(() => {
184
- if (!isTopLayer || !onClickOutside) return
215
+ useCallback(
216
+ (event: MouseEvent) => {
217
+ if (!isTopLayer || !onClickOutside) return
218
+
219
+ const target = event.target as Node | null
185
220
 
186
- onClickOutside()
187
- }, [isTopLayer, onClickOutside]),
221
+ if (target && !isTargetWithinScope(boundaryElement, portalElement, target)) {
222
+ // Ignore clicks outside of the scope
223
+ return
224
+ }
225
+
226
+ onClickOutside()
227
+ },
228
+ [boundaryElement, isTopLayer, onClickOutside, portalElement]
229
+ ),
188
230
  [rootElement]
189
231
  )
190
232
 
@@ -265,16 +307,21 @@ export const Dialog = forwardRef(function Dialog(
265
307
  footer,
266
308
  header,
267
309
  id,
310
+ onActivate,
268
311
  onClickOutside,
269
312
  onClose,
313
+ onFocus,
270
314
  padding: paddingProp = 4,
271
- portal,
315
+ portal: portalProp,
272
316
  position: positionProp = dialog.position || 'fixed',
273
317
  scheme,
274
318
  width: widthProp = 0,
275
319
  zOffset: zOffsetProp = dialog.zOffset || theme.sanity.layer?.dialog.zOffset,
276
320
  ...restProps
277
321
  } = props
322
+ const portal = usePortal()
323
+ const portalElement = portalProp ? portal.elements?.[portalProp] || null : portal.element
324
+ const boundaryElement = useBoundaryElement().element
278
325
  const cardRadius = useArrayProp(cardRadiusProp)
279
326
  const padding = useArrayProp(paddingProp)
280
327
  const position = useArrayProp(positionProp)
@@ -283,32 +330,67 @@ export const Dialog = forwardRef(function Dialog(
283
330
  const preDivRef = useRef<HTMLDivElement | null>(null)
284
331
  const postDivRef = useRef<HTMLDivElement | null>(null)
285
332
  const cardRef = useRef<HTMLDivElement | null>(null)
333
+ const focusedElementRef = useRef<HTMLElement | null>(null)
286
334
 
287
- const handleFocus = useCallback((event: React.FocusEvent<HTMLDivElement>) => {
288
- const target = event.target
289
- const cardElement = cardRef.current
335
+ const handleFocus = useCallback(
336
+ (event: React.FocusEvent<HTMLDivElement>) => {
337
+ onFocus?.(event)
290
338
 
291
- if (!cardElement) {
292
- return
293
- }
339
+ const target = event.target
340
+ const cardElement = cardRef.current
294
341
 
295
- if (target === preDivRef.current) {
296
- focusLastDescendant(cardElement)
342
+ if (cardElement && target === preDivRef.current) {
343
+ focusLastDescendant(cardElement)
297
344
 
298
- return
299
- }
345
+ return
346
+ }
300
347
 
301
- if (target === postDivRef.current) {
302
- focusFirstDescendant(cardElement)
348
+ if (cardElement && target === postDivRef.current) {
349
+ focusFirstDescendant(cardElement)
303
350
 
304
- return
305
- }
306
- }, [])
351
+ return
352
+ }
353
+
354
+ if (isHTMLElement(event.target)) {
355
+ focusedElementRef.current = event.target
356
+ }
357
+ },
358
+ [onFocus]
359
+ )
307
360
 
308
361
  const labelId = `${id}_label`
309
362
 
363
+ const rootClickTimeoutRef = useRef<NodeJS.Timeout>()
364
+
365
+ // If the resulting active element (a.k.a. focused element) is not withing scope when clicking
366
+ // within the dialog, then we want to focus the previously interactive element in the dialog instead.
367
+ // This is to allow the user to tab or close the dialog by pressing escape.
368
+ const handleRootClick = useCallback(() => {
369
+ if (rootClickTimeoutRef.current) {
370
+ clearTimeout(rootClickTimeoutRef.current)
371
+ }
372
+
373
+ rootClickTimeoutRef.current = setTimeout(() => {
374
+ const activeElement = document.activeElement
375
+
376
+ if (activeElement && !isTargetWithinScope(boundaryElement, portalElement, activeElement)) {
377
+ const target = focusedElementRef.current
378
+
379
+ if (!target || !document.body.contains(target)) {
380
+ // No previously focused element, or it's not in the document anymore
381
+ const cardElement = cardRef.current
382
+ if (cardElement) focusFirstDescendant(cardElement)
383
+
384
+ return
385
+ }
386
+
387
+ target.focus()
388
+ }
389
+ }, 0)
390
+ }, [boundaryElement, portalElement])
391
+
310
392
  return (
311
- <Portal __unstable_name={portal}>
393
+ <Portal __unstable_name={portalProp}>
312
394
  <Root
313
395
  {...restProps}
314
396
  $padding={padding}
@@ -317,6 +399,8 @@ export const Dialog = forwardRef(function Dialog(
317
399
  aria-modal
318
400
  data-ui="Dialog"
319
401
  id={id}
402
+ onActivate={onActivate}
403
+ onClick={handleRootClick}
320
404
  onFocus={handleFocus}
321
405
  ref={ref}
322
406
  role="dialog"
@@ -332,6 +416,7 @@ export const Dialog = forwardRef(function Dialog(
332
416
  id={id}
333
417
  onClickOutside={onClickOutside}
334
418
  onClose={onClose}
419
+ portal={portalProp}
335
420
  radius={cardRadius}
336
421
  ref={cardRef}
337
422
  scheme={scheme}
@@ -1,19 +1,18 @@
1
1
  import {AnimatePresence, motion} from 'framer-motion'
2
- import {useCallback, useEffect, useMemo, useRef, useState} from 'react'
2
+ import {useCallback, useEffect, useMemo, useRef, useState, startTransition} from 'react'
3
3
  import styled from 'styled-components'
4
+ import {useMounted} from '../../hooks/useMounted'
4
5
  import {Box} from '../../primitives'
5
6
  import {Layer} from '../../utils'
6
7
  import {Toast} from './toast'
7
8
  import {ToastContext} from './toastContext'
8
9
  import {ToastContextValue, ToastParams} from './types'
9
10
 
10
- interface ToastState {
11
- toasts: {
12
- dismiss: () => void
13
- id: string
14
- params: ToastParams
15
- }[]
16
- }
11
+ type ToastState = {
12
+ dismiss: () => void
13
+ id: string
14
+ params: ToastParams
15
+ }[]
17
16
 
18
17
  /**
19
18
  * @public
@@ -51,25 +50,29 @@ let toastId = 0
51
50
  */
52
51
  export function ToastProvider(props: ToastProviderProps): React.ReactElement {
53
52
  const {children, padding = 4, paddingX, paddingY, zOffset} = props
54
- const [state, setState] = useState<ToastState>({toasts: []})
53
+ const [state, _setState] = useState<ToastState>([])
54
+
55
55
  const toastsRef = useRef<{[key: string]: {timeoutId: NodeJS.Timeout}}>({})
56
56
 
57
57
  const push = useCallback((params: ToastParams) => {
58
+ // Wrap setState in startTransition to allow React to give input state updates higher priority
59
+ const setState: typeof _setState = (state) => startTransition(() => _setState(state))
60
+
58
61
  const id = params.id || String(toastId++)
59
62
  const duration = params.duration || 5000
60
63
 
61
64
  const dismiss = () => {
62
65
  const timeoutId = toastsRef.current[id]?.timeoutId
63
66
 
64
- setState((prevState) => {
65
- const idx = prevState.toasts.findIndex((t) => t.id === id)
67
+ setState((prevState): ToastState => {
68
+ const idx = prevState.findIndex((t) => t.id === id)
66
69
 
67
70
  if (idx > -1) {
68
- const toasts = prevState.toasts.slice(0)
71
+ const toasts = prevState.slice(0)
69
72
 
70
73
  toasts.splice(idx, 1)
71
74
 
72
- return {...prevState, toasts}
75
+ return toasts
73
76
  }
74
77
 
75
78
  return prevState
@@ -81,19 +84,16 @@ export function ToastProvider(props: ToastProviderProps): React.ReactElement {
81
84
  }
82
85
  }
83
86
 
84
- setState((prevState) => {
85
- return {
86
- ...prevState,
87
- toasts: prevState.toasts
88
- .filter((t) => t.id !== id)
89
- .concat([
90
- {
91
- dismiss,
92
- id,
93
- params: {...params, duration},
94
- },
95
- ]),
96
- }
87
+ setState((prevState): ToastState => {
88
+ return prevState
89
+ .filter((t) => t.id !== id)
90
+ .concat([
91
+ {
92
+ dismiss,
93
+ id,
94
+ params: {...params, duration},
95
+ },
96
+ ])
97
97
  })
98
98
 
99
99
  if (toastsRef.current[id]) {
@@ -119,37 +119,40 @@ export function ToastProvider(props: ToastProviderProps): React.ReactElement {
119
119
  )
120
120
 
121
121
  const value: ToastContextValue = useMemo(() => ({version: 0.0, push}), [push])
122
+ const mounted = useMounted()
122
123
 
123
124
  return (
124
125
  <ToastContext.Provider value={value}>
125
126
  {children}
126
127
 
127
- <Root data-ui="ToastProvider" zOffset={zOffset}>
128
- <ToastContainer>
129
- <Box padding={padding} paddingX={paddingX} paddingY={paddingY}>
130
- <AnimatePresence initial={false}>
131
- {state.toasts.map(({dismiss, id, params}) => (
132
- <motion.div
133
- animate={{opacity: 1, y: 0, scale: 1}}
134
- exit={{opacity: 0, scale: 0.5, transition: {duration: 0.2}}}
135
- initial={{opacity: 0, y: 32, scale: 0.25}}
136
- key={id}
137
- layout="position"
138
- transition={{type: 'spring', damping: 30, stiffness: 400}}
139
- >
140
- <Toast
141
- closable={params.closable}
142
- description={params.description}
143
- onClose={dismiss}
144
- status={params.status}
145
- title={params.title}
146
- />
147
- </motion.div>
148
- ))}
149
- </AnimatePresence>
150
- </Box>
151
- </ToastContainer>
152
- </Root>
128
+ {mounted && (
129
+ <Root data-ui="ToastProvider" zOffset={zOffset}>
130
+ <ToastContainer>
131
+ <Box padding={padding} paddingX={paddingX} paddingY={paddingY}>
132
+ <AnimatePresence initial={false}>
133
+ {state.map(({dismiss, id, params}) => (
134
+ <motion.div
135
+ animate={{opacity: 1, y: 0, scale: 1}}
136
+ exit={{opacity: 0, scale: 0.5, transition: {duration: 0.2}}}
137
+ initial={{opacity: 0, y: 32, scale: 0.25}}
138
+ key={id}
139
+ layout="position"
140
+ transition={{type: 'spring', damping: 30, stiffness: 400}}
141
+ >
142
+ <Toast
143
+ closable={params.closable}
144
+ description={params.description}
145
+ onClose={dismiss}
146
+ status={params.status}
147
+ title={params.title}
148
+ />
149
+ </motion.div>
150
+ ))}
151
+ </AnimatePresence>
152
+ </Box>
153
+ </ToastContainer>
154
+ </Root>
155
+ )}
153
156
  </ToastContext.Provider>
154
157
  )
155
158
  }
package/src/constants.ts CHANGED
@@ -1,12 +1,12 @@
1
1
  /**
2
2
  * @internal
3
3
  */
4
- export const EMPTY_ARRAY: any[] = []
4
+ export const EMPTY_ARRAY: never[] = []
5
5
 
6
6
  /**
7
7
  * @internal
8
8
  */
9
- export const EMPTY_RECORD: Record<string, any> = {}
9
+ export const EMPTY_RECORD: Record<string, never> = {}
10
10
 
11
11
  /**
12
12
  * @internal
@@ -46,3 +46,10 @@ export function isHTMLSelectElement(element: unknown): element is HTMLSelectElem
46
46
  export function isHTMLTextAreaElement(element: unknown): element is HTMLTextAreaElement {
47
47
  return isHTMLElement(element) && element.nodeName === 'TEXTAREA'
48
48
  }
49
+
50
+ /**
51
+ * @internal
52
+ */
53
+ export function containsOrEqualsElement(element: HTMLElement, node: Node): boolean {
54
+ return element.contains(node) || element === node
55
+ }
@@ -5,5 +5,6 @@ export * from './useElementSize'
5
5
  export * from './useGlobalKeyDown'
6
6
  export * from './useMediaIndex'
7
7
  export * from './usePrefersDark'
8
+ export * from './usePrefersReducedMotion'
8
9
  export * from './useForwardedRef'
9
10
  export * from './useCustomValidity'
@@ -12,7 +12,8 @@ export function useArrayProp<T extends ArrayPropPrimitive = ArrayPropPrimitive>(
12
12
  val: T | T[] | undefined,
13
13
  defaultVal?: T[]
14
14
  ): T[] {
15
- const __perf_hash__ = JSON.stringify(val ?? defaultVal)
15
+ // JSON.stringify is fast, but it's not faster than useMemo's referencial equality check
16
+ const __perf_hash__ = useMemo(() => JSON.stringify(val ?? defaultVal), [defaultVal, val])
16
17
 
17
18
  return useMemo(
18
19
  () => _getArrayProp(val, defaultVal),
@@ -0,0 +1,31 @@
1
+ /** @jest-environment node */
2
+ import {renderToString, renderToStaticMarkup} from 'react-dom/server'
3
+ import {studioTheme, ThemeProvider} from '../../theme'
4
+ import {useMediaIndex} from './useMediaIndex'
5
+
6
+ function Log() {
7
+ const mediaIndex = useMediaIndex()
8
+
9
+ return <>mediaIndex: {JSON.stringify(mediaIndex)}</>
10
+ }
11
+
12
+ describe('useMediaIndex', () => {
13
+ it(`SSR to static markup returns 0`, () => {
14
+ expect(
15
+ renderToStaticMarkup(
16
+ <ThemeProvider theme={studioTheme}>
17
+ <Log />
18
+ </ThemeProvider>
19
+ )
20
+ ).toBe('mediaIndex: 0')
21
+ })
22
+ it(`SSR to markup for hydration doesn't throw`, () => {
23
+ expect(
24
+ renderToString(
25
+ <ThemeProvider theme={studioTheme}>
26
+ <Log />
27
+ </ThemeProvider>
28
+ )
29
+ ).toMatchInlineSnapshot(`"mediaIndex: <!-- -->0"`)
30
+ })
31
+ })
@@ -1,6 +1,95 @@
1
- import {useEffect, useMemo, useState} from 'react'
1
+ import {useSyncExternalStore} from 'react'
2
2
  import {useTheme} from '../../theme'
3
- import {_getMediaManager} from './lib/media'
3
+
4
+ /**
5
+ * @internal
6
+ */
7
+ export interface _MediaStore {
8
+ subscribe: (onStoreChange: () => void) => () => void
9
+ getSnapshot: () => number
10
+ }
11
+
12
+ const MEDIA_STORE_CACHE = new WeakMap<number[], _MediaStore>()
13
+
14
+ type MediaQueryMinWidth = `(min-width: ${number}px)`
15
+ type MediaQueryMaxWidth = `(max-width: ${number}px)`
16
+ type MediaQueryMinMaxWidth = `${MediaQueryMinWidth} and ${MediaQueryMaxWidth}`
17
+ type MediaQuery = `screen and ${MediaQueryMinWidth | MediaQueryMaxWidth | MediaQueryMinMaxWidth}`
18
+
19
+ function _getMediaQuery(media: number[], index: number): MediaQuery {
20
+ if (index === 0) {
21
+ return `screen and (max-width: ${media[index] - 1}px)`
22
+ }
23
+
24
+ if (index === media.length) {
25
+ return `screen and (min-width: ${media[index - 1]}px)`
26
+ }
27
+
28
+ return `screen and (min-width: ${media[index - 1]}px) and (max-width: ${media[index] - 1}px)`
29
+ }
30
+
31
+ function _createMediaStore(media: number[]): _MediaStore {
32
+ const mediaLen = media.length
33
+ let sizes: {mq: MediaQueryList; index: number}[]
34
+
35
+ // The _createMediaStore function is called in both server and client environments.
36
+ // However since subscribe and getSnapshot are only called on the client we lazy init what we need for them
37
+ // so that we don't need to run checks for wether it's safe to call `window.matchMedia`
38
+ const getSizes = () => {
39
+ if (!sizes) {
40
+ sizes = []
41
+
42
+ for (let index = mediaLen; index > -1; index -= 1) {
43
+ const mediaQuery = _getMediaQuery(media, index)
44
+
45
+ sizes.push({index, mq: window.matchMedia(mediaQuery)})
46
+ }
47
+ }
48
+
49
+ return sizes
50
+ }
51
+
52
+ const getSnapshot = () => {
53
+ for (const {index, mq} of getSizes()) {
54
+ if (mq.matches) return index
55
+ }
56
+
57
+ return 0
58
+ }
59
+
60
+ const subscribe = (onStoreChange: () => void) => {
61
+ const disposeFns: (() => void)[] = []
62
+
63
+ for (const {mq} of getSizes()) {
64
+ const handleChange = () => {
65
+ if (mq.matches) onStoreChange()
66
+ }
67
+
68
+ mq.addEventListener('change', handleChange)
69
+
70
+ disposeFns.push(() => mq.removeEventListener('change', handleChange))
71
+ }
72
+
73
+ return () => {
74
+ for (const disposeFn of disposeFns) {
75
+ disposeFn()
76
+ }
77
+ }
78
+ }
79
+
80
+ return {getSnapshot, subscribe}
81
+ }
82
+
83
+ /**
84
+ * Only called during server-side rendering, and hydration if using hydrateRoot
85
+ * Since the server environment doesn't have access to the DOM, we can't determine the current value of the media query
86
+ * and we assume `(prefers-color-scheme: light)` since it's the most common scheme
87
+ *
88
+ * @link https://beta.reactjs.org/apis/react/useSyncExternalStore#adding-support-for-server-rendering
89
+ */
90
+ function getServerSnapshot() {
91
+ return 0
92
+ }
4
93
 
5
94
  /**
6
95
  * This API might change. DO NOT USE IN PRODUCTION.
@@ -9,10 +98,13 @@ import {_getMediaManager} from './lib/media'
9
98
  export function useMediaIndex(): number {
10
99
  const theme = useTheme()
11
100
  const {media} = theme.sanity
12
- const manager = useMemo(() => _getMediaManager(media), [media])
13
- const [index, setIndex] = useState(manager.getCurrentIndex)
14
101
 
15
- useEffect(() => manager.subscribe(setIndex), [manager])
102
+ let store = MEDIA_STORE_CACHE.get(media)
103
+
104
+ if (!store) {
105
+ store = _createMediaStore(media)
106
+ MEDIA_STORE_CACHE.set(media, store)
107
+ }
16
108
 
17
- return index
109
+ return useSyncExternalStore(store.subscribe, store.getSnapshot, getServerSnapshot)
18
110
  }
@@ -0,0 +1,14 @@
1
+ import {useEffect, useReducer} from 'react'
2
+
3
+ /**
4
+ * Some components should only render after mounting to the DOM, and not be rendered at all during SSR renderToString or equivalent.
5
+ * @public
6
+ */
7
+ export function useMounted(): boolean {
8
+ // Use useReducer instead of useState as it's more low-level and creates the least amount of functions for the garbage collector to clean up
9
+ const [mounted, mount] = useReducer(() => true, false)
10
+
11
+ useEffect(mount, [mount])
12
+
13
+ return mounted
14
+ }
@@ -0,0 +1,56 @@
1
+ /** @jest-environment jsdom */
2
+ /**
3
+ * As this hook is used for top-level theming it's likely to be called while server-rendering
4
+ * and that's why it's worth it to have a testing suite for hydration
5
+ */
6
+ import {waitFor} from '@testing-library/dom'
7
+ import {hydrateRoot} from 'react-dom/client'
8
+
9
+ import {usePrefersDark} from './usePrefersDark'
10
+
11
+ function Log() {
12
+ const dark = usePrefersDark()
13
+
14
+ return <>dark: {JSON.stringify(dark)}</>
15
+ }
16
+
17
+ const originalMatchMedia = window.matchMedia
18
+
19
+ describe('usePrefersDark SSR hydration', () => {
20
+ beforeAll(() => {
21
+ window.matchMedia = () =>
22
+ ({
23
+ addEventListener: jest.fn(),
24
+ removeEventListener: jest.fn(),
25
+ matches: true,
26
+ } as any)
27
+ })
28
+
29
+ afterAll(() => {
30
+ window.matchMedia = originalMatchMedia
31
+ })
32
+
33
+ it(`hydrates without any warnings`, async () => {
34
+ const spy = jest.spyOn(console, 'error').mockImplementation()
35
+
36
+ const node = document.createElement('div')
37
+
38
+ document.body.appendChild(node)
39
+
40
+ node.innerHTML = `dark: <!-- -->false`
41
+
42
+ hydrateRoot(node, <Log />)
43
+
44
+ // It's false initially
45
+ await waitFor(() => expect(node.innerHTML).toBe('dark: <!-- -->false'))
46
+
47
+ // After hydration it should switch to true
48
+ await waitFor(() => expect(node.innerHTML).toBe('dark: <!-- -->true'))
49
+
50
+ // eslint-disable-next-line no-console
51
+ expect(console.error).not.toHaveBeenCalled()
52
+
53
+ spy.mockReset()
54
+ spy.mockRestore()
55
+ })
56
+ })