@sanity/ui 1.0.7 → 1.0.8

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sanity/ui",
3
- "version": "1.0.7",
3
+ "version": "1.0.8",
4
4
  "sideEffects": false,
5
5
  "types": "./dist/index.d.ts",
6
6
  "source": "./src/index.ts",
@@ -49,7 +49,7 @@
49
49
  "@sanity/color": "^2.2.0",
50
50
  "@sanity/icons": "^2.2.0",
51
51
  "csstype": "^3.1.1",
52
- "framer-motion": "^7.10.3",
52
+ "framer-motion": "^8.1.1",
53
53
  "react-refractor": "^2.1.7"
54
54
  },
55
55
  "devDependencies": {
@@ -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
  }
@@ -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
+ })
@@ -0,0 +1,18 @@
1
+ /** @jest-environment node */
2
+ import {renderToString, renderToStaticMarkup} from 'react-dom/server'
3
+ import {usePrefersDark} from './usePrefersDark'
4
+
5
+ function Log() {
6
+ const dark = usePrefersDark()
7
+
8
+ return <>dark: {JSON.stringify(dark)}</>
9
+ }
10
+
11
+ describe('usePrefersDark', () => {
12
+ it(`SSR to static markup returns false`, () => {
13
+ expect(renderToStaticMarkup(<Log />)).toBe('dark: false')
14
+ })
15
+ it(`SSR to markup for hydration doesn't throw`, () => {
16
+ expect(renderToString(<Log />)).toMatchInlineSnapshot(`"dark: <!-- -->false"`)
17
+ })
18
+ })
@@ -1,28 +1,61 @@
1
- import {useEffect, useMemo, useState} from 'react'
1
+ import {useSyncExternalStore} from 'react'
2
+
3
+ let MEDIA_QUERY_CACHE: MediaQueryList | undefined
2
4
 
3
5
  /**
4
- * @public
6
+ * Lazy init the matchMedia instance
5
7
  */
6
- export function usePrefersDark(): boolean {
7
- const mq = useMemo(() => {
8
- if (typeof window === 'undefined') return undefined
9
-
10
- return window.matchMedia('(prefers-color-scheme: dark)')
11
- }, [])
12
-
13
- const [dark, setDark] = useState(mq?.matches || false)
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
+ }
14
17
 
15
- useEffect(() => {
16
- if (!mq) return undefined
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()
17
30
 
18
- setDark(mq.matches)
31
+ matchMedia.addEventListener('change', onStoreChange)
19
32
 
20
- const handleChange = () => setDark(mq.matches)
33
+ return () => matchMedia.removeEventListener('change', onStoreChange)
34
+ }
21
35
 
22
- mq.addEventListener('change', handleChange)
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
+ }
23
44
 
24
- return () => mq.removeEventListener('change', handleChange)
25
- }, [mq])
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
49
+ *
50
+ * @link https://beta.reactjs.org/apis/react/useSyncExternalStore#adding-support-for-server-rendering
51
+ */
52
+ function getServerSnapshot() {
53
+ return false
54
+ }
26
55
 
27
- return dark
56
+ /**
57
+ * @public
58
+ */
59
+ export function usePrefersDark(): boolean {
60
+ return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
28
61
  }
@@ -4,6 +4,6 @@ import {ResizeObserver as ResizeObserverPolyfill} from '@juggle/resize-observer'
4
4
  * @internal
5
5
  */
6
6
  export const _ResizeObserver: typeof ResizeObserver =
7
- typeof window !== 'undefined' && window.ResizeObserver
7
+ typeof document !== 'undefined' && window.ResizeObserver
8
8
  ? window.ResizeObserver
9
9
  : ResizeObserverPolyfill
@@ -11,7 +11,7 @@ export const defaultContextValue: PortalContextValue = {
11
11
  version: 0.0,
12
12
  boundaryElement: null,
13
13
  get element() {
14
- if (typeof window === 'undefined') {
14
+ if (typeof document === 'undefined') {
15
15
  return null
16
16
  }
17
17
 
@@ -19,7 +19,7 @@ export interface PortalProviderProps {
19
19
  __unstable_elements?: Record<string, HTMLElement | null | undefined>
20
20
  }
21
21
 
22
- const __BROWSER__ = typeof window !== 'undefined'
22
+ const __BROWSER__ = typeof document !== 'undefined'
23
23
 
24
24
  /**
25
25
  * @public
@@ -1,88 +0,0 @@
1
- /**
2
- * @internal
3
- */
4
- export interface _MediaManager {
5
- getCurrentIndex: () => number
6
- subscribe: (subscriber: (index: number) => void) => () => void
7
- }
8
-
9
- const MEDIA_MANAGER_CACHE = new WeakMap<number[], _MediaManager>()
10
-
11
- function _getMediaQuery(media: number[], index: number) {
12
- if (index === 0) {
13
- return `screen and (max-width: ${media[index] - 1}px)`
14
- }
15
-
16
- if (index === media.length) {
17
- return `screen and (min-width: ${media[index - 1]}px)`
18
- }
19
-
20
- return `screen and (min-width: ${media[index - 1]}px) and (max-width: ${media[index] - 1}px)`
21
- }
22
-
23
- function _createMediaManager(media: number[]): _MediaManager {
24
- const mediaLen = media.length
25
- const sizes: {mq: MediaQueryList; index: number}[] = []
26
-
27
- if (typeof window !== 'undefined') {
28
- for (let index = mediaLen; index > -1; index -= 1) {
29
- const mediaQuery = _getMediaQuery(media, index)
30
-
31
- sizes.push({index, mq: window.matchMedia(mediaQuery)})
32
- }
33
- }
34
-
35
- const getCurrentIndex = () => {
36
- for (const {index, mq} of sizes) {
37
- if (mq.matches) return index
38
- }
39
-
40
- return 0
41
- }
42
-
43
- const subscribe = (subscriber: (index: number) => void) => {
44
- const disposeFns: (() => void)[] = []
45
-
46
- for (const {index, mq} of sizes) {
47
- const handleChange = () => {
48
- if (mq.matches) subscriber(index)
49
- }
50
-
51
- if (mq.addEventListener) {
52
- mq.addEventListener('change', handleChange)
53
- } else {
54
- mq.addListener(handleChange)
55
- }
56
-
57
- disposeFns.push(() => {
58
- if (mq.removeEventListener) {
59
- mq.removeEventListener('change', handleChange)
60
- } else {
61
- mq.removeListener(handleChange)
62
- }
63
- })
64
- }
65
-
66
- return () => {
67
- for (const disposeFn of disposeFns) {
68
- disposeFn()
69
- }
70
- }
71
- }
72
-
73
- return {getCurrentIndex, subscribe}
74
- }
75
-
76
- /**
77
- * @internal
78
- */
79
- export function _getMediaManager(media: number[]): _MediaManager {
80
- let manager = MEDIA_MANAGER_CACHE.get(media)
81
-
82
- if (!manager) {
83
- manager = _createMediaManager(media)
84
- MEDIA_MANAGER_CACHE.set(media, manager)
85
- }
86
-
87
- return manager
88
- }