@sanity/sdk-react 0.0.0-alpha.1 → 0.0.0-alpha.11
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/README.md +163 -0
- package/dist/_chunks-es/context.js +8 -0
- package/dist/_chunks-es/context.js.map +1 -0
- package/dist/_chunks-es/useLogOut.js +29 -20
- package/dist/_chunks-es/useLogOut.js.map +1 -1
- package/dist/components.d.ts +24 -149
- package/dist/components.js +33 -153
- package/dist/components.js.map +1 -1
- package/dist/context.d.ts +45 -0
- package/dist/context.js +5 -0
- package/dist/context.js.map +1 -0
- package/dist/hooks.d.ts +3401 -12
- package/dist/hooks.js +210 -15
- package/dist/hooks.js.map +1 -1
- package/package.json +42 -32
- package/src/_exports/components.ts +2 -12
- package/src/_exports/context.ts +2 -0
- package/src/_exports/hooks.ts +25 -0
- package/src/components/Login/LoginLinks.test.tsx +7 -16
- package/src/components/Login/LoginLinks.tsx +14 -29
- package/src/components/SanityApp.test.tsx +54 -0
- package/src/components/SanityApp.tsx +53 -0
- package/src/components/auth/AuthBoundary.test.tsx +17 -21
- package/src/components/auth/AuthBoundary.tsx +14 -7
- package/src/components/auth/Login.test.tsx +2 -16
- package/src/components/auth/Login.tsx +11 -30
- package/src/components/auth/LoginCallback.test.tsx +2 -17
- package/src/components/auth/LoginCallback.tsx +5 -10
- package/src/components/auth/LoginError.test.tsx +2 -17
- package/src/components/auth/LoginError.tsx +11 -16
- package/src/components/auth/LoginFooter.test.tsx +2 -16
- package/src/components/auth/LoginFooter.tsx +8 -24
- package/src/components/auth/LoginLayout.test.tsx +2 -16
- package/src/components/auth/LoginLayout.tsx +8 -38
- package/src/components/auth/authTestHelpers.tsx +11 -0
- package/src/{components/context → context}/SanityProvider.test.tsx +1 -1
- package/src/{components/context → context}/SanityProvider.tsx +12 -6
- package/src/hooks/auth/useAuthState.test.tsx +10 -100
- package/src/hooks/auth/useAuthState.tsx +5 -10
- package/src/hooks/auth/useAuthToken.test.tsx +10 -88
- package/src/hooks/auth/useAuthToken.tsx +4 -10
- package/src/hooks/auth/useCurrentUser.test.tsx +10 -44
- package/src/hooks/auth/useCurrentUser.tsx +22 -22
- package/src/hooks/auth/useHandleCallback.test.tsx +10 -19
- package/src/hooks/auth/useHandleCallback.tsx +4 -9
- package/src/hooks/auth/useLogOut.test.tsx +11 -62
- package/src/hooks/auth/useLogOut.tsx +4 -9
- package/src/hooks/auth/useLoginUrls.test.tsx +47 -40
- package/src/hooks/auth/useLoginUrls.tsx +7 -6
- package/src/hooks/client/useClient.test.tsx +1 -1
- package/src/hooks/client/useClient.ts +3 -3
- package/src/hooks/comlink/useFrameConnection.test.tsx +122 -0
- package/src/hooks/comlink/useFrameConnection.ts +111 -0
- package/src/hooks/comlink/useWindowConnection.test.ts +94 -0
- package/src/hooks/comlink/useWindowConnection.ts +82 -0
- package/src/hooks/context/useSanityInstance.test.tsx +1 -1
- package/src/hooks/context/useSanityInstance.ts +4 -4
- package/src/hooks/document/useApplyActions.test.ts +24 -0
- package/src/hooks/document/useApplyActions.ts +24 -0
- package/src/hooks/document/useDocument.test.ts +81 -0
- package/src/hooks/document/useDocument.ts +38 -0
- package/src/hooks/document/useDocumentEvent.test.ts +53 -0
- package/src/hooks/document/useDocumentEvent.ts +22 -0
- package/src/hooks/document/useDocumentSyncStatus.test.ts +16 -0
- package/src/hooks/document/useDocumentSyncStatus.ts +6 -0
- package/src/hooks/document/useEditDocument.test.ts +172 -0
- package/src/hooks/document/useEditDocument.ts +80 -0
- package/src/hooks/documentCollection/useDocuments.test.ts +130 -0
- package/src/hooks/documentCollection/useDocuments.ts +135 -0
- package/src/hooks/helpers/createCallbackHook.test.tsx +106 -0
- package/src/hooks/helpers/createCallbackHook.tsx +15 -0
- package/src/hooks/helpers/createStateSourceHook.test.tsx +130 -0
- package/src/hooks/helpers/createStateSourceHook.tsx +21 -0
- package/src/hooks/preview/usePreview.test.tsx +175 -0
- package/src/hooks/preview/usePreview.tsx +120 -0
- package/src/vite-env.d.ts +10 -0
- package/src/components/DocumentGridLayout/DocumentGridLayout.stories.tsx +0 -95
- package/src/components/DocumentGridLayout/DocumentGridLayout.test.tsx +0 -42
- package/src/components/DocumentGridLayout/DocumentGridLayout.tsx +0 -23
- package/src/components/DocumentListLayout/DocumentListLayout.stories.tsx +0 -95
- package/src/components/DocumentListLayout/DocumentListLayout.test.tsx +0 -42
- package/src/components/DocumentListLayout/DocumentListLayout.tsx +0 -15
- package/src/components/DocumentPreviewLayout/DocumentPreviewLayout.md +0 -49
- package/src/components/DocumentPreviewLayout/DocumentPreviewLayout.stories.tsx +0 -34
- package/src/components/DocumentPreviewLayout/DocumentPreviewLayout.test.tsx +0 -30
- package/src/components/DocumentPreviewLayout/DocumentPreviewLayout.tsx +0 -115
- package/src/hooks/Documents/.keep +0 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import {createDocumentListStore, type DocumentHandle, type DocumentListOptions} from '@sanity/sdk'
|
|
2
|
+
import {useCallback, useEffect, useState, useSyncExternalStore} from 'react'
|
|
3
|
+
|
|
4
|
+
import {useSanityInstance} from '../context/useSanityInstance'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @public
|
|
8
|
+
*/
|
|
9
|
+
export interface DocumentCollection {
|
|
10
|
+
/** Retrieve more documents matching the provided options */
|
|
11
|
+
loadMore: () => void
|
|
12
|
+
/** The retrieved document handles of the documents matching the provided options */
|
|
13
|
+
results: DocumentHandle[]
|
|
14
|
+
/** Whether a retrieval of documents is in flight */
|
|
15
|
+
isPending: boolean
|
|
16
|
+
/** Whether more documents exist that match the provided options than have been retrieved */
|
|
17
|
+
hasMore: boolean
|
|
18
|
+
/** The total number of documents in the collection */
|
|
19
|
+
count: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type DocumentListStore = ReturnType<typeof createDocumentListStore>
|
|
23
|
+
type DocumentListState = ReturnType<DocumentListStore['getState']>['getCurrent']
|
|
24
|
+
const STABLE_EMPTY = {
|
|
25
|
+
results: [],
|
|
26
|
+
isPending: false,
|
|
27
|
+
hasMore: false,
|
|
28
|
+
count: 0,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @public
|
|
33
|
+
*
|
|
34
|
+
* The `useDocuments` hook retrieves and provides access to a live collection of documents, optionally filtered, sorted, and matched to a given Content Lake perspective.
|
|
35
|
+
* Because the returned document collection is live, the results will update in real time until the component invoking the hook is unmounted.
|
|
36
|
+
*
|
|
37
|
+
* @param options - Options for narrowing and sorting the document collection
|
|
38
|
+
* @returns The collection of documents matching the provided options (if any), as well as properties describing the collection and a function to load more.
|
|
39
|
+
*
|
|
40
|
+
* @example Retrieving all documents of type 'movie'
|
|
41
|
+
* ```
|
|
42
|
+
* const { results, isPending } = useDocuments({ filter: '_type == "movie"' })
|
|
43
|
+
*
|
|
44
|
+
* return (
|
|
45
|
+
* <div>
|
|
46
|
+
* <h1>Movies</h1>
|
|
47
|
+
* {results && (
|
|
48
|
+
* <ul>
|
|
49
|
+
* {results.map(movie => (<li key={movie._id}>…</li>))}
|
|
50
|
+
* </ul>
|
|
51
|
+
* )}
|
|
52
|
+
* {isPending && <div>Loading movies…</div>}
|
|
53
|
+
* </div>
|
|
54
|
+
* )
|
|
55
|
+
* ```
|
|
56
|
+
*
|
|
57
|
+
* @example Retrieving all movies released since 1980, sorted by director’s last name
|
|
58
|
+
* ```
|
|
59
|
+
* const { results } = useDocuments({
|
|
60
|
+
* filter: '_type == "movie" && releaseDate >= "1980-01-01"',
|
|
61
|
+
* sort: [
|
|
62
|
+
* {
|
|
63
|
+
* // Expand the `director` reference field with the dereferencing operator `->`
|
|
64
|
+
* field: 'director->lastName',
|
|
65
|
+
* sort: 'asc',
|
|
66
|
+
* },
|
|
67
|
+
* ],
|
|
68
|
+
* })
|
|
69
|
+
*
|
|
70
|
+
* return (
|
|
71
|
+
* <div>
|
|
72
|
+
* <h1>Movies released since 1980</h1>
|
|
73
|
+
* {results && (
|
|
74
|
+
* <ol>
|
|
75
|
+
* {results.map(movie => (<li key={movie._id}>…</li>))}
|
|
76
|
+
* </ol>
|
|
77
|
+
* )}
|
|
78
|
+
* </div>
|
|
79
|
+
* )
|
|
80
|
+
* ```
|
|
81
|
+
*/
|
|
82
|
+
export function useDocuments(options: DocumentListOptions = {}): DocumentCollection {
|
|
83
|
+
const instance = useSanityInstance()
|
|
84
|
+
|
|
85
|
+
// NOTE: useState is used because it guaranteed to return a stable reference
|
|
86
|
+
// across renders
|
|
87
|
+
const [ref] = useState<{
|
|
88
|
+
storeInstance: DocumentListStore | null
|
|
89
|
+
getCurrent: DocumentListState
|
|
90
|
+
initialOptions: DocumentListOptions
|
|
91
|
+
}>(() => ({
|
|
92
|
+
storeInstance: null,
|
|
93
|
+
getCurrent: () => STABLE_EMPTY,
|
|
94
|
+
initialOptions: options,
|
|
95
|
+
}))
|
|
96
|
+
|
|
97
|
+
// serialize options to ensure it only calls `setOptions` when the values
|
|
98
|
+
// themselves changes (in cases where devs put config inline)
|
|
99
|
+
const serializedOptions = JSON.stringify(options)
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
ref.storeInstance?.setOptions(JSON.parse(serializedOptions))
|
|
102
|
+
}, [ref, serializedOptions])
|
|
103
|
+
|
|
104
|
+
const subscribe = useCallback(
|
|
105
|
+
(onStoreChanged: () => void) => {
|
|
106
|
+
// to match the lifecycle of `useSyncExternalState`, we create the store
|
|
107
|
+
// instance after subscribe and mutate the ref to connect everything
|
|
108
|
+
ref.storeInstance = createDocumentListStore(instance)
|
|
109
|
+
ref.storeInstance.setOptions(ref.initialOptions)
|
|
110
|
+
const state = ref.storeInstance.getState()
|
|
111
|
+
ref.getCurrent = state.getCurrent
|
|
112
|
+
const unsubscribe = state.subscribe(onStoreChanged)
|
|
113
|
+
|
|
114
|
+
return () => {
|
|
115
|
+
// unsubscribe to clean up the state subscriptions
|
|
116
|
+
unsubscribe()
|
|
117
|
+
// dispose of the instance
|
|
118
|
+
ref.storeInstance?.dispose()
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
[instance, ref],
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
const getSnapshot = useCallback(() => {
|
|
125
|
+
return ref.getCurrent()
|
|
126
|
+
}, [ref])
|
|
127
|
+
|
|
128
|
+
const state = useSyncExternalStore(subscribe, getSnapshot)
|
|
129
|
+
|
|
130
|
+
const loadMore = useCallback(() => {
|
|
131
|
+
ref.storeInstance?.loadMore()
|
|
132
|
+
}, [ref])
|
|
133
|
+
|
|
134
|
+
return {loadMore, ...state}
|
|
135
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import {createSanityInstance, type SanityInstance} from '@sanity/sdk'
|
|
2
|
+
import {renderHook} from '@testing-library/react'
|
|
3
|
+
import {describe, expect, it, vi} from 'vitest'
|
|
4
|
+
|
|
5
|
+
import {useSanityInstance} from '../context/useSanityInstance'
|
|
6
|
+
import {createCallbackHook} from './createCallbackHook'
|
|
7
|
+
|
|
8
|
+
// Mock the useSanityInstance hook
|
|
9
|
+
vi.mock('../context/useSanityInstance', () => ({
|
|
10
|
+
useSanityInstance: vi.fn(),
|
|
11
|
+
}))
|
|
12
|
+
|
|
13
|
+
describe('createCallbackHook', () => {
|
|
14
|
+
// Reset all mocks before each test
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
vi.clearAllMocks()
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('should create a hook that provides a memoized callback', () => {
|
|
20
|
+
// Create a mock Sanity instance
|
|
21
|
+
const mockInstance = createSanityInstance({projectId: 'p', dataset: 'd'})
|
|
22
|
+
|
|
23
|
+
// Mock the useSanityInstance to return our mock instance
|
|
24
|
+
vi.mocked(useSanityInstance).mockReturnValue(mockInstance)
|
|
25
|
+
|
|
26
|
+
// Create a test callback function
|
|
27
|
+
const testCallback = (instance: object, param1: string, param2: number) => {
|
|
28
|
+
return `${param1}-${param2}-${instance ? 'valid' : 'invalid'}`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Create our hook using the utility
|
|
32
|
+
const useTestHook = createCallbackHook(testCallback)
|
|
33
|
+
|
|
34
|
+
// Render the hook
|
|
35
|
+
const {result, rerender} = renderHook(() => useTestHook())
|
|
36
|
+
|
|
37
|
+
// Test the callback with parameters
|
|
38
|
+
const result1 = result.current('test', 123)
|
|
39
|
+
expect(result1).toBe('test-123-valid')
|
|
40
|
+
|
|
41
|
+
// Rerender and ensure the callback reference remains stable
|
|
42
|
+
rerender()
|
|
43
|
+
const result2 = result.current('test', 123)
|
|
44
|
+
expect(result2).toBe('test-123-valid')
|
|
45
|
+
|
|
46
|
+
// Verify the hook is memoizing the callback
|
|
47
|
+
expect(result.current).toBe(result.current)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('should create new callback when instance changes', () => {
|
|
51
|
+
// Create two different mock instances
|
|
52
|
+
const mockInstance1 = createSanityInstance({projectId: 'p1', dataset: 'd'})
|
|
53
|
+
const mockInstance2 = createSanityInstance({projectId: 'p2', dataset: 'd'})
|
|
54
|
+
|
|
55
|
+
vi.mocked(useSanityInstance).mockReturnValueOnce(mockInstance1)
|
|
56
|
+
|
|
57
|
+
// Create a test callback
|
|
58
|
+
const testCallback = (instance: SanityInstance) => instance.identity.projectId
|
|
59
|
+
|
|
60
|
+
// Create and render our hook
|
|
61
|
+
const useTestHook = createCallbackHook(testCallback)
|
|
62
|
+
const {result, rerender} = renderHook(() => useTestHook())
|
|
63
|
+
|
|
64
|
+
// Store the first callback reference
|
|
65
|
+
const firstCallback = result.current
|
|
66
|
+
|
|
67
|
+
// Change the instance
|
|
68
|
+
vi.mocked(useSanityInstance).mockReturnValueOnce(mockInstance2)
|
|
69
|
+
rerender()
|
|
70
|
+
|
|
71
|
+
// Verify the callback reference changed
|
|
72
|
+
expect(result.current).not.toBe(firstCallback)
|
|
73
|
+
|
|
74
|
+
// Verify the callbacks return different results
|
|
75
|
+
expect(firstCallback()).toBe('p1')
|
|
76
|
+
expect(result.current()).toBe('p2')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('should handle callbacks with multiple parameters', () => {
|
|
80
|
+
const mockInstance = createSanityInstance({projectId: 'p', dataset: 'd'})
|
|
81
|
+
vi.mocked(useSanityInstance).mockReturnValue(mockInstance)
|
|
82
|
+
|
|
83
|
+
// Create a callback with multiple parameters
|
|
84
|
+
const testCallback = (
|
|
85
|
+
instance: SanityInstance,
|
|
86
|
+
path: string,
|
|
87
|
+
method: string,
|
|
88
|
+
data: object,
|
|
89
|
+
) => ({
|
|
90
|
+
url: `${instance.identity.projectId}${path}`,
|
|
91
|
+
method,
|
|
92
|
+
data,
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
const useTestHook = createCallbackHook(testCallback)
|
|
96
|
+
const {result} = renderHook(() => useTestHook())
|
|
97
|
+
|
|
98
|
+
const response = result.current('/users', 'POST', {name: 'Test User'})
|
|
99
|
+
|
|
100
|
+
expect(response).toEqual({
|
|
101
|
+
url: 'p/users',
|
|
102
|
+
method: 'POST',
|
|
103
|
+
data: {name: 'Test User'},
|
|
104
|
+
})
|
|
105
|
+
})
|
|
106
|
+
})
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import {type SanityInstance} from '@sanity/sdk'
|
|
2
|
+
import {useCallback} from 'react'
|
|
3
|
+
|
|
4
|
+
import {useSanityInstance} from '../context/useSanityInstance'
|
|
5
|
+
|
|
6
|
+
export function createCallbackHook<TParams extends unknown[], TReturn>(
|
|
7
|
+
callback: (instance: SanityInstance, ...params: TParams) => TReturn,
|
|
8
|
+
): () => (...params: TParams) => TReturn {
|
|
9
|
+
function useHook() {
|
|
10
|
+
const instance = useSanityInstance()
|
|
11
|
+
return useCallback((...params: TParams) => callback(instance, ...params), [instance])
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
return useHook
|
|
15
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import {createSanityInstance, type SanityInstance} from '@sanity/sdk'
|
|
2
|
+
import {renderHook} from '@testing-library/react'
|
|
3
|
+
import {throwError} from 'rxjs'
|
|
4
|
+
import {describe, expect, it, vi} from 'vitest'
|
|
5
|
+
|
|
6
|
+
import {useSanityInstance} from '../context/useSanityInstance'
|
|
7
|
+
import {createStateSourceHook} from './createStateSourceHook'
|
|
8
|
+
|
|
9
|
+
// Mock the useSanityInstance hook
|
|
10
|
+
vi.mock('../context/useSanityInstance', () => ({
|
|
11
|
+
useSanityInstance: vi.fn(),
|
|
12
|
+
}))
|
|
13
|
+
|
|
14
|
+
describe('createStateSourceHook', () => {
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
vi.clearAllMocks()
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('should create a hook that provides access to state source', () => {
|
|
20
|
+
const mockInstance = createSanityInstance({projectId: 'p', dataset: 'd'})
|
|
21
|
+
vi.mocked(useSanityInstance).mockReturnValue(mockInstance)
|
|
22
|
+
|
|
23
|
+
const mockState = {count: 0}
|
|
24
|
+
const mockSubscribe = vi.fn()
|
|
25
|
+
const mockGetCurrent = vi.fn(() => mockState)
|
|
26
|
+
|
|
27
|
+
const stateSourceFactory = vi.fn().mockReturnValue({
|
|
28
|
+
subscribe: mockSubscribe,
|
|
29
|
+
getCurrent: mockGetCurrent,
|
|
30
|
+
observable: throwError(() => new Error('unexpected usage of observable')),
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
const useTestHook = createStateSourceHook(stateSourceFactory)
|
|
34
|
+
const {result} = renderHook(() => useTestHook(0))
|
|
35
|
+
|
|
36
|
+
expect(stateSourceFactory).toHaveBeenCalledWith(mockInstance, 0)
|
|
37
|
+
expect(mockGetCurrent).toHaveBeenCalled()
|
|
38
|
+
expect(result.current).toBe(mockState)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('should recreate state source when params change', () => {
|
|
42
|
+
const mockInstance = createSanityInstance({projectId: 'p', dataset: 'd'})
|
|
43
|
+
vi.mocked(useSanityInstance).mockReturnValue(mockInstance)
|
|
44
|
+
|
|
45
|
+
const subscribe = vi.fn()
|
|
46
|
+
const stateSourceFactory = vi.fn((_instance: SanityInstance, count: number) => ({
|
|
47
|
+
subscribe,
|
|
48
|
+
getCurrent: () => count,
|
|
49
|
+
observable: throwError(() => new Error('unexpected usage of observable')),
|
|
50
|
+
}))
|
|
51
|
+
|
|
52
|
+
const useTestHook = createStateSourceHook(stateSourceFactory)
|
|
53
|
+
const {result, rerender} = renderHook(({count}) => useTestHook(count), {
|
|
54
|
+
initialProps: {count: 0},
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
expect(result.current).toEqual(0)
|
|
58
|
+
expect(stateSourceFactory).toHaveBeenCalledWith(mockInstance, 0)
|
|
59
|
+
|
|
60
|
+
rerender({count: 1})
|
|
61
|
+
|
|
62
|
+
expect(result.current).toEqual(1)
|
|
63
|
+
expect(stateSourceFactory).toHaveBeenCalledWith(mockInstance, 1)
|
|
64
|
+
expect(stateSourceFactory).toHaveBeenCalledTimes(2)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('should recreate state source when instance changes', () => {
|
|
68
|
+
const mockInstance1 = createSanityInstance({projectId: 'p1', dataset: 'd'})
|
|
69
|
+
const mockInstance2 = createSanityInstance({projectId: 'p2', dataset: 'd'})
|
|
70
|
+
|
|
71
|
+
vi.mocked(useSanityInstance).mockReturnValueOnce(mockInstance1)
|
|
72
|
+
|
|
73
|
+
const stateSourceFactory = vi.fn((instance: SanityInstance) => ({
|
|
74
|
+
subscribe: vi.fn(),
|
|
75
|
+
getCurrent: () => instance.identity.projectId,
|
|
76
|
+
observable: throwError(() => new Error('unexpected usage of observable')),
|
|
77
|
+
}))
|
|
78
|
+
|
|
79
|
+
const useTestHook = createStateSourceHook(stateSourceFactory)
|
|
80
|
+
const {result, rerender} = renderHook(() => useTestHook())
|
|
81
|
+
|
|
82
|
+
expect(result.current).toEqual('p1')
|
|
83
|
+
|
|
84
|
+
vi.mocked(useSanityInstance).mockReturnValueOnce(mockInstance2)
|
|
85
|
+
rerender()
|
|
86
|
+
|
|
87
|
+
expect(result.current).toEqual('p2')
|
|
88
|
+
expect(stateSourceFactory).toHaveBeenCalledTimes(2)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('should handle subscription functionality', () => {
|
|
92
|
+
const mockInstance = createSanityInstance({projectId: 'p', dataset: 'd'})
|
|
93
|
+
vi.mocked(useSanityInstance).mockReturnValue(mockInstance)
|
|
94
|
+
|
|
95
|
+
const mockSubscribe = vi.fn()
|
|
96
|
+
const mockGetCurrent = vi.fn().mockReturnValue({value: 'initial'})
|
|
97
|
+
|
|
98
|
+
const stateSourceFactory = vi.fn(() => ({
|
|
99
|
+
subscribe: mockSubscribe,
|
|
100
|
+
getCurrent: mockGetCurrent,
|
|
101
|
+
observable: throwError(() => new Error('unexpected usage of observable')),
|
|
102
|
+
}))
|
|
103
|
+
|
|
104
|
+
const useTestHook = createStateSourceHook(stateSourceFactory)
|
|
105
|
+
renderHook(() => useTestHook())
|
|
106
|
+
|
|
107
|
+
expect(mockSubscribe).toHaveBeenCalled()
|
|
108
|
+
const subscriber = mockSubscribe.mock.calls[0][0]
|
|
109
|
+
expect(typeof subscriber).toBe('function')
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('should handle multiple parameters', () => {
|
|
113
|
+
const mockInstance = createSanityInstance({projectId: 'p', dataset: 'd'})
|
|
114
|
+
vi.mocked(useSanityInstance).mockReturnValue(mockInstance)
|
|
115
|
+
|
|
116
|
+
const stateSourceFactory = vi.fn(
|
|
117
|
+
(_instance: SanityInstance, param1: string, param2: number) => ({
|
|
118
|
+
subscribe: vi.fn(),
|
|
119
|
+
getCurrent: () => `${param1}_${param2}`,
|
|
120
|
+
observable: throwError(() => new Error('unexpected usage of observable')),
|
|
121
|
+
}),
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
const useTestHook = createStateSourceHook(stateSourceFactory)
|
|
125
|
+
const {result} = renderHook(() => useTestHook('test', 123))
|
|
126
|
+
|
|
127
|
+
expect(result.current).toEqual('test_123')
|
|
128
|
+
expect(stateSourceFactory).toHaveBeenCalledWith(mockInstance, 'test', 123)
|
|
129
|
+
})
|
|
130
|
+
})
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import {type SanityInstance, type StateSource} from '@sanity/sdk'
|
|
2
|
+
import {useMemo, useSyncExternalStore} from 'react'
|
|
3
|
+
|
|
4
|
+
import {useSanityInstance} from '../context/useSanityInstance'
|
|
5
|
+
|
|
6
|
+
export function createStateSourceHook<TParams extends unknown[], TState>(
|
|
7
|
+
stateSourceFactory: (instance: SanityInstance, ...params: TParams) => StateSource<TState>,
|
|
8
|
+
): (...params: TParams) => TState {
|
|
9
|
+
function useHook(...params: TParams) {
|
|
10
|
+
const instance = useSanityInstance()
|
|
11
|
+
const {subscribe, getCurrent} = useMemo(
|
|
12
|
+
() => stateSourceFactory(instance, ...params),
|
|
13
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
14
|
+
[instance, ...params],
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
return useSyncExternalStore(subscribe, getCurrent)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return useHook
|
|
21
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import {type DocumentHandle, getPreviewState, type PreviewValue, resolvePreview} from '@sanity/sdk'
|
|
2
|
+
import {act, render, screen} from '@testing-library/react'
|
|
3
|
+
import {Suspense, useRef} from 'react'
|
|
4
|
+
import {type Mock} from 'vitest'
|
|
5
|
+
|
|
6
|
+
import {usePreview} from './usePreview'
|
|
7
|
+
|
|
8
|
+
// Mock IntersectionObserver
|
|
9
|
+
const mockIntersectionObserver = vi.fn()
|
|
10
|
+
let intersectionObserverCallback: (entries: IntersectionObserverEntry[]) => void
|
|
11
|
+
|
|
12
|
+
beforeAll(() => {
|
|
13
|
+
vi.stubGlobal(
|
|
14
|
+
'IntersectionObserver',
|
|
15
|
+
class {
|
|
16
|
+
constructor(callback: (entries: IntersectionObserverEntry[]) => void) {
|
|
17
|
+
intersectionObserverCallback = callback
|
|
18
|
+
mockIntersectionObserver(callback)
|
|
19
|
+
}
|
|
20
|
+
observe = vi.fn()
|
|
21
|
+
disconnect = vi.fn()
|
|
22
|
+
},
|
|
23
|
+
)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
// Mock the preview store
|
|
27
|
+
vi.mock('@sanity/sdk', () => {
|
|
28
|
+
const getCurrent = vi.fn()
|
|
29
|
+
const subscribe = vi.fn()
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
resolvePreview: vi.fn(),
|
|
33
|
+
getPreviewState: vi.fn().mockReturnValue({getCurrent, subscribe}),
|
|
34
|
+
}
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
vi.mock('../context/useSanityInstance', () => ({
|
|
38
|
+
useSanityInstance: () => ({}),
|
|
39
|
+
}))
|
|
40
|
+
|
|
41
|
+
const mockDocument: DocumentHandle = {
|
|
42
|
+
_id: 'doc1',
|
|
43
|
+
_type: 'exampleType',
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function TestComponent({document}: {document: DocumentHandle}) {
|
|
47
|
+
const ref = useRef(null)
|
|
48
|
+
const {results, isPending} = usePreview({document, ref})
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<div ref={ref}>
|
|
52
|
+
<h1>{results?.title}</h1>
|
|
53
|
+
<p>{results?.subtitle}</p>
|
|
54
|
+
{isPending && <div>Pending...</div>}
|
|
55
|
+
</div>
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
describe('usePreview', () => {
|
|
60
|
+
let getCurrent: Mock
|
|
61
|
+
let subscribe: Mock
|
|
62
|
+
|
|
63
|
+
beforeEach(() => {
|
|
64
|
+
// @ts-expect-error mock does not need param
|
|
65
|
+
getCurrent = getPreviewState().getCurrent as Mock
|
|
66
|
+
// @ts-expect-error mock does not need param
|
|
67
|
+
subscribe = getPreviewState().subscribe as Mock
|
|
68
|
+
|
|
69
|
+
// Reset all mocks between tests
|
|
70
|
+
getCurrent.mockReset()
|
|
71
|
+
subscribe.mockReset()
|
|
72
|
+
mockIntersectionObserver.mockReset()
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test('it only subscribes when element is visible', async () => {
|
|
76
|
+
// Setup initial state
|
|
77
|
+
getCurrent.mockReturnValue({
|
|
78
|
+
results: {title: 'Initial Title', subtitle: 'Initial Subtitle'},
|
|
79
|
+
isPending: false,
|
|
80
|
+
})
|
|
81
|
+
const eventsUnsubscribe = vi.fn()
|
|
82
|
+
subscribe.mockImplementation(() => eventsUnsubscribe)
|
|
83
|
+
|
|
84
|
+
render(
|
|
85
|
+
<Suspense fallback={<div>Loading...</div>}>
|
|
86
|
+
<TestComponent document={mockDocument} />
|
|
87
|
+
</Suspense>,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
// Initially, element is not intersecting
|
|
91
|
+
expect(screen.getByText('Initial Title')).toBeInTheDocument()
|
|
92
|
+
expect(subscribe).not.toHaveBeenCalled()
|
|
93
|
+
|
|
94
|
+
// Simulate element becoming visible
|
|
95
|
+
await act(async () => {
|
|
96
|
+
intersectionObserverCallback([{isIntersecting: true} as IntersectionObserverEntry])
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
// After element becomes visible, events subscription should be active
|
|
100
|
+
expect(subscribe).toHaveBeenCalled()
|
|
101
|
+
expect(eventsUnsubscribe).not.toHaveBeenCalled()
|
|
102
|
+
|
|
103
|
+
// Simulate element becoming hidden
|
|
104
|
+
await act(async () => {
|
|
105
|
+
intersectionObserverCallback([{isIntersecting: false} as IntersectionObserverEntry])
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
// When hidden, should maintain last known state
|
|
109
|
+
expect(screen.getByText('Initial Title')).toBeInTheDocument()
|
|
110
|
+
expect(eventsUnsubscribe).toHaveBeenCalled()
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
test.skip('it suspends and resolves data when element becomes visible', async () => {
|
|
114
|
+
// Initial setup with pending state
|
|
115
|
+
getCurrent.mockReturnValueOnce([null, true])
|
|
116
|
+
const resolvePromise = Promise.resolve<PreviewValue>({
|
|
117
|
+
title: 'Resolved Title',
|
|
118
|
+
subtitle: 'Resolved Subtitle',
|
|
119
|
+
media: null,
|
|
120
|
+
})
|
|
121
|
+
;(resolvePreview as Mock).mockReturnValueOnce(resolvePromise)
|
|
122
|
+
|
|
123
|
+
let subscriber: () => void
|
|
124
|
+
subscribe.mockImplementation((sub: () => void) => {
|
|
125
|
+
subscriber = sub
|
|
126
|
+
return vi.fn()
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
render(
|
|
130
|
+
<Suspense fallback={<div>Loading...</div>}>
|
|
131
|
+
<TestComponent document={mockDocument} />
|
|
132
|
+
</Suspense>,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
expect(screen.getByText('Loading...')).toBeInTheDocument()
|
|
136
|
+
|
|
137
|
+
// Simulate element becoming visible
|
|
138
|
+
await act(async () => {
|
|
139
|
+
intersectionObserverCallback([{isIntersecting: true} as IntersectionObserverEntry])
|
|
140
|
+
await resolvePromise
|
|
141
|
+
getCurrent.mockReturnValue({
|
|
142
|
+
results: {title: 'Resolved Title', subtitle: 'Resolved Subtitle'},
|
|
143
|
+
isPending: false,
|
|
144
|
+
})
|
|
145
|
+
subscriber?.()
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
expect(screen.getByText('Resolved Title')).toBeInTheDocument()
|
|
149
|
+
expect(screen.getByText('Resolved Subtitle')).toBeInTheDocument()
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
test('it handles environments without IntersectionObserver', async () => {
|
|
153
|
+
// Temporarily remove IntersectionObserver
|
|
154
|
+
const originalIntersectionObserver = window.IntersectionObserver
|
|
155
|
+
// @ts-expect-error - Intentionally removing IntersectionObserver
|
|
156
|
+
delete window.IntersectionObserver
|
|
157
|
+
|
|
158
|
+
getCurrent.mockReturnValue({
|
|
159
|
+
results: {title: 'Fallback Title', subtitle: 'Fallback Subtitle'},
|
|
160
|
+
isPending: false,
|
|
161
|
+
})
|
|
162
|
+
subscribe.mockImplementation(() => vi.fn())
|
|
163
|
+
|
|
164
|
+
render(
|
|
165
|
+
<Suspense fallback={<div>Loading...</div>}>
|
|
166
|
+
<TestComponent document={mockDocument} />
|
|
167
|
+
</Suspense>,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
expect(screen.getByText('Fallback Title')).toBeInTheDocument()
|
|
171
|
+
|
|
172
|
+
// Restore IntersectionObserver
|
|
173
|
+
window.IntersectionObserver = originalIntersectionObserver
|
|
174
|
+
})
|
|
175
|
+
})
|