@sanity/sdk-react 0.0.0-alpha.21 → 0.0.0-alpha.23

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 (71) hide show
  1. package/dist/index.d.ts +502 -3460
  2. package/dist/index.js +400 -465
  3. package/dist/index.js.map +1 -1
  4. package/package.json +17 -15
  5. package/src/_exports/index.ts +4 -5
  6. package/src/components/SDKProvider.test.tsx +78 -54
  7. package/src/components/SDKProvider.tsx +31 -26
  8. package/src/components/SanityApp.test.tsx +121 -15
  9. package/src/components/SanityApp.tsx +26 -15
  10. package/src/components/auth/AuthBoundary.test.tsx +32 -14
  11. package/src/components/auth/AuthBoundary.tsx +53 -23
  12. package/src/components/auth/LoginCallback.test.tsx +19 -6
  13. package/src/components/auth/LoginCallback.tsx +2 -11
  14. package/src/components/auth/LoginError.test.tsx +12 -4
  15. package/src/components/auth/LoginError.tsx +13 -21
  16. package/src/components/auth/LoginFooter.test.tsx +7 -3
  17. package/src/context/ResourceProvider.test.tsx +157 -0
  18. package/src/context/ResourceProvider.tsx +111 -0
  19. package/src/context/SanityInstanceContext.ts +1 -1
  20. package/src/hooks/auth/useLoginUrl.tsx +14 -0
  21. package/src/hooks/client/useClient.ts +2 -1
  22. package/src/hooks/comlink/useManageFavorite.test.ts +16 -8
  23. package/src/hooks/comlink/useManageFavorite.ts +37 -13
  24. package/src/hooks/comlink/useRecordDocumentHistoryEvent.test.ts +8 -4
  25. package/src/hooks/comlink/useRecordDocumentHistoryEvent.ts +10 -8
  26. package/src/hooks/context/useSanityInstance.test.tsx +157 -15
  27. package/src/hooks/context/useSanityInstance.ts +66 -26
  28. package/src/hooks/dashboard/useNavigateToStudioDocument.test.ts +13 -31
  29. package/src/hooks/dashboard/useNavigateToStudioDocument.ts +12 -15
  30. package/src/hooks/dashboard/{useStudioWorkspacesByResourceId.test.tsx → useStudioWorkspacesByProjectIdDataset.test.tsx} +13 -13
  31. package/src/hooks/dashboard/{useStudioWorkspacesByResourceId.ts → useStudioWorkspacesByProjectIdDataset.ts} +10 -9
  32. package/src/hooks/datasets/useDatasets.ts +15 -4
  33. package/src/hooks/document/useApplyDocumentActions.test.ts +4 -9
  34. package/src/hooks/document/useApplyDocumentActions.ts +6 -31
  35. package/src/hooks/document/useDocument.test.ts +2 -2
  36. package/src/hooks/document/useDocument.ts +40 -19
  37. package/src/hooks/document/useDocumentEvent.test.ts +2 -3
  38. package/src/hooks/document/useDocumentEvent.ts +7 -11
  39. package/src/hooks/document/useDocumentPermissions.test.ts +204 -0
  40. package/src/hooks/document/useDocumentPermissions.ts +31 -23
  41. package/src/hooks/document/useDocumentSyncStatus.ts +5 -4
  42. package/src/hooks/document/useEditDocument.test.ts +2 -3
  43. package/src/hooks/document/useEditDocument.ts +43 -29
  44. package/src/hooks/documents/useDocuments.test.tsx +30 -3
  45. package/src/hooks/documents/useDocuments.ts +20 -7
  46. package/src/hooks/helpers/createCallbackHook.test.tsx +2 -2
  47. package/src/hooks/helpers/createCallbackHook.tsx +2 -3
  48. package/src/hooks/helpers/createStateSourceHook.test.tsx +1 -1
  49. package/src/hooks/helpers/createStateSourceHook.tsx +5 -8
  50. package/src/hooks/paginatedDocuments/usePaginatedDocuments.test.tsx +43 -18
  51. package/src/hooks/paginatedDocuments/usePaginatedDocuments.ts +36 -50
  52. package/src/hooks/preview/usePreview.test.tsx +66 -7
  53. package/src/hooks/preview/usePreview.tsx +17 -12
  54. package/src/hooks/projection/useProjection.test.tsx +68 -3
  55. package/src/hooks/projection/useProjection.ts +21 -24
  56. package/src/hooks/projects/useProject.ts +7 -4
  57. package/src/hooks/query/useQuery.ts +32 -14
  58. package/src/hooks/users/useUsers.test.tsx +330 -0
  59. package/src/hooks/users/useUsers.ts +65 -52
  60. package/src/components/Login/LoginLinks.test.tsx +0 -90
  61. package/src/components/Login/LoginLinks.tsx +0 -58
  62. package/src/components/auth/Login.test.tsx +0 -27
  63. package/src/components/auth/Login.tsx +0 -39
  64. package/src/components/auth/LoginLayout.test.tsx +0 -19
  65. package/src/components/auth/LoginLayout.tsx +0 -69
  66. package/src/components/auth/authTestHelpers.tsx +0 -11
  67. package/src/context/SanityProvider.test.tsx +0 -25
  68. package/src/context/SanityProvider.tsx +0 -50
  69. package/src/hooks/auth/useLoginUrls.test.tsx +0 -68
  70. package/src/hooks/auth/useLoginUrls.tsx +0 -52
  71. package/src/hooks/users/useUsers.test.ts +0 -163
@@ -0,0 +1,111 @@
1
+ import {createSanityInstance, type SanityConfig, type SanityInstance} from '@sanity/sdk'
2
+ import {Suspense, use, useEffect, useMemo, useRef} from 'react'
3
+
4
+ import {SanityInstanceContext} from './SanityInstanceContext'
5
+
6
+ const DEFAULT_FALLBACK = (
7
+ <>
8
+ Warning: No fallback provided. Please supply a fallback prop to ensure proper Suspense handling.
9
+ </>
10
+ )
11
+
12
+ /**
13
+ * Props for the ResourceProvider component
14
+ * @internal
15
+ */
16
+ export interface ResourceProviderProps extends SanityConfig {
17
+ /**
18
+ * React node to show while content is loading
19
+ * Used as the fallback for the internal Suspense boundary
20
+ */
21
+ fallback: React.ReactNode
22
+ children: React.ReactNode
23
+ }
24
+
25
+ /**
26
+ * Provides a Sanity instance to child components through React Context
27
+ *
28
+ * @internal
29
+ *
30
+ * @remarks
31
+ * The ResourceProvider creates a hierarchical structure of Sanity instances:
32
+ * - When used as a root provider, it creates a new Sanity instance with the given config
33
+ * - When nested inside another ResourceProvider, it creates a child instance that
34
+ * inherits and extends the parent's configuration
35
+ *
36
+ * Features:
37
+ * - Automatically manages the lifecycle of Sanity instances
38
+ * - Disposes instances when the component unmounts
39
+ * - Includes a Suspense boundary for data loading
40
+ * - Enables hierarchical configuration inheritance
41
+ *
42
+ * Use this component to:
43
+ * - Set up project/dataset configuration for an application
44
+ * - Override specific configuration values in a section of your app
45
+ * - Create isolated instance hierarchies for different features
46
+ *
47
+ * @example Creating a root provider
48
+ * ```tsx
49
+ * <ResourceProvider
50
+ * projectId="your-project-id"
51
+ * dataset="production"
52
+ * fallback={<LoadingSpinner />}
53
+ * >
54
+ * <YourApp />
55
+ * </ResourceProvider>
56
+ * ```
57
+ *
58
+ * @example Creating nested providers with configuration inheritance
59
+ * ```tsx
60
+ * // Root provider with production config with nested provider for preview features with custom dataset
61
+ * <ResourceProvider projectId="abc123" dataset="production" fallback={<Loading />}>
62
+ * <div>...Main app content</div>
63
+ * <Dashboard />
64
+ * <ResourceProvider dataset="preview" fallback={<Loading />}>
65
+ * <PreviewFeatures />
66
+ * </ResourceProvider>
67
+ * </ResourceProvider>
68
+ * ```
69
+ */
70
+ export function ResourceProvider({
71
+ children,
72
+ fallback,
73
+ ...config
74
+ }: ResourceProviderProps): React.ReactNode {
75
+ const parent = use(SanityInstanceContext)
76
+ const instance = useMemo(
77
+ () => (parent ? parent.createChild(config) : createSanityInstance(config)),
78
+ [config, parent],
79
+ )
80
+
81
+ // Ref to hold the scheduled disposal timer.
82
+ const disposal = useRef<{
83
+ instance: SanityInstance
84
+ timeoutId: ReturnType<typeof setTimeout>
85
+ } | null>(null)
86
+
87
+ useEffect(() => {
88
+ // If the component remounts quickly (as in Strict Mode), cancel any pending disposal.
89
+ if (disposal.current !== null && instance === disposal.current.instance) {
90
+ clearTimeout(disposal.current.timeoutId)
91
+ disposal.current = null
92
+ }
93
+
94
+ return () => {
95
+ disposal.current = {
96
+ instance,
97
+ timeoutId: setTimeout(() => {
98
+ if (!instance.isDisposed()) {
99
+ instance.dispose()
100
+ }
101
+ }, 0),
102
+ }
103
+ }
104
+ }, [instance])
105
+
106
+ return (
107
+ <SanityInstanceContext.Provider value={instance}>
108
+ <Suspense fallback={fallback ?? DEFAULT_FALLBACK}>{children}</Suspense>
109
+ </SanityInstanceContext.Provider>
110
+ )
111
+ }
@@ -1,4 +1,4 @@
1
1
  import {type SanityInstance} from '@sanity/sdk'
2
2
  import {createContext} from 'react'
3
3
 
4
- export const SanityInstanceContext = createContext<SanityInstance[] | null>(null)
4
+ export const SanityInstanceContext = createContext<SanityInstance | null>(null)
@@ -0,0 +1,14 @@
1
+ import {getLoginUrlState} from '@sanity/sdk'
2
+ import {useMemo, useSyncExternalStore} from 'react'
3
+
4
+ import {useSanityInstance} from '../context/useSanityInstance'
5
+
6
+ /**
7
+ * @public
8
+ */
9
+ export function useLoginUrl(): string {
10
+ const instance = useSanityInstance()
11
+ const {subscribe, getCurrent} = useMemo(() => getLoginUrlState(instance), [instance])
12
+
13
+ return useSyncExternalStore(subscribe, getCurrent as () => string)
14
+ }
@@ -1,4 +1,5 @@
1
1
  import {getClientState} from '@sanity/sdk'
2
+ import {identity} from 'rxjs'
2
3
 
3
4
  import {createStateSourceHook} from '../helpers/createStateSourceHook'
4
5
 
@@ -31,5 +32,5 @@ import {createStateSourceHook} from '../helpers/createStateSourceHook'
31
32
  */
32
33
  export const useClient = createStateSourceHook({
33
34
  getState: getClientState,
34
- getResourceId: (e) => e.resourceId,
35
+ getConfig: identity,
35
36
  })
@@ -57,11 +57,15 @@ describe('useManageFavorite', () => {
57
57
  })
58
58
 
59
59
  expect(node.post).toHaveBeenCalledWith('dashboard/v1/events/favorite/mutate', {
60
- documentId: 'mock-id',
61
- documentType: 'mock-type',
60
+ document: {
61
+ id: 'mock-id',
62
+ type: 'mock-type',
63
+ resource: {
64
+ id: 'test.test',
65
+ type: 'studio',
66
+ },
67
+ },
62
68
  eventType: 'added',
63
- resourceType: 'studio',
64
- resourceId: undefined,
65
69
  })
66
70
  expect(result.current.isFavorited).toBe(true)
67
71
  })
@@ -74,11 +78,15 @@ describe('useManageFavorite', () => {
74
78
  })
75
79
 
76
80
  expect(node.post).toHaveBeenCalledWith('dashboard/v1/events/favorite/mutate', {
77
- documentId: 'mock-id',
78
- documentType: 'mock-type',
81
+ document: {
82
+ id: 'mock-id',
83
+ type: 'mock-type',
84
+ resource: {
85
+ id: 'test.test',
86
+ type: 'studio',
87
+ },
88
+ },
79
89
  eventType: 'removed',
80
- resourceType: 'studio',
81
- resourceId: undefined,
82
90
  })
83
91
  expect(result.current.isFavorited).toBe(false)
84
92
  })
@@ -7,9 +7,10 @@ import {
7
7
  SDK_NODE_NAME,
8
8
  type StudioResource,
9
9
  } from '@sanity/message-protocol'
10
- import {type FrameMessage} from '@sanity/sdk'
11
- import {useCallback, useState} from 'react'
10
+ import {type DocumentHandle, type FrameMessage} from '@sanity/sdk'
11
+ import {useCallback, useEffect, useState} from 'react'
12
12
 
13
+ import {useSanityInstance} from '../context/useSanityInstance'
13
14
  import {useWindowConnection} from './useWindowConnection'
14
15
 
15
16
  // should we import this whole type from the message protocol?
@@ -21,9 +22,7 @@ interface ManageFavorite {
21
22
  isConnected: boolean
22
23
  }
23
24
 
24
- interface UseManageFavoriteProps {
25
- documentId: string
26
- documentType: string
25
+ interface UseManageFavoriteProps extends DocumentHandle {
27
26
  resourceId?: string
28
27
  resourceType: StudioResource['type'] | MediaResource['type'] | CanvasResource['type']
29
28
  }
@@ -64,21 +63,43 @@ interface UseManageFavoriteProps {
64
63
  export function useManageFavorite({
65
64
  documentId,
66
65
  documentType,
67
- resourceId,
66
+ projectId: paramProjectId,
67
+ dataset: paramDataset,
68
+ resourceId: paramResourceId,
68
69
  resourceType,
69
70
  }: UseManageFavoriteProps): ManageFavorite {
70
71
  const [isFavorited, setIsFavorited] = useState(false) // should load this from a comlink fetch
71
72
  const [status, setStatus] = useState<Status>('idle')
73
+ const [resourceId, setResourceId] = useState<string>(paramResourceId || '')
72
74
  const {sendMessage} = useWindowConnection<Events.FavoriteMessage, FrameMessage>({
73
75
  name: SDK_NODE_NAME,
74
76
  connectTo: SDK_CHANNEL_NAME,
75
77
  onStatus: setStatus,
76
78
  })
79
+ const instance = useSanityInstance()
80
+ const {config} = instance
81
+ const instanceProjectId = config?.projectId
82
+ const instanceDataset = config?.dataset
83
+ const projectId = paramProjectId ?? instanceProjectId
84
+ const dataset = paramDataset ?? instanceDataset
77
85
 
78
- if (resourceType !== 'studio' && !resourceId) {
79
- throw new Error('resourceId is required for media-library and canvas resources')
86
+ if (resourceType === 'studio' && (!projectId || !dataset)) {
87
+ throw new Error('projectId and dataset are required for studio resources')
80
88
  }
81
89
 
90
+ useEffect(() => {
91
+ // If resourceType is studio and the resourceId is not provided,
92
+ // use the projectId and dataset to generate a resourceId
93
+ if (resourceType === 'studio' && !paramResourceId) {
94
+ setResourceId(`${projectId}.${dataset}`)
95
+ } else if (paramResourceId) {
96
+ setResourceId(paramResourceId)
97
+ } else {
98
+ // For other resource types, resourceId is required
99
+ throw new Error('resourceId is required for media-library and canvas resources')
100
+ }
101
+ }, [resourceType, paramResourceId, projectId, dataset])
102
+
82
103
  const handleFavoriteAction = useCallback(
83
104
  (action: 'added' | 'removed', setFavoriteState: boolean) => {
84
105
  if (!documentId || !documentType || !resourceType) return
@@ -88,11 +109,14 @@ export function useManageFavorite({
88
109
  type: 'dashboard/v1/events/favorite/mutate',
89
110
  data: {
90
111
  eventType: action,
91
- documentId,
92
- documentType,
93
- resourceType,
94
- // Resource Id should exist for media-library and canvas resources
95
- resourceId: resourceId!,
112
+ document: {
113
+ id: documentId,
114
+ type: documentType,
115
+ resource: {
116
+ id: resourceId,
117
+ type: resourceType,
118
+ },
119
+ },
96
120
  },
97
121
  response: {
98
122
  success: true,
@@ -62,10 +62,14 @@ describe('useRecordDocumentHistoryEvent', () => {
62
62
 
63
63
  expect(node.post).toHaveBeenCalledWith('dashboard/v1/events/history', {
64
64
  eventType: 'viewed',
65
- documentId: 'mock-id',
66
- documentType: 'mock-type',
67
- resourceType: 'studio',
68
- resourceId: 'mock-resource-id',
65
+ document: {
66
+ id: 'mock-id',
67
+ type: 'mock-type',
68
+ resource: {
69
+ id: 'mock-resource-id',
70
+ type: 'studio',
71
+ },
72
+ },
69
73
  })
70
74
  })
71
75
 
@@ -7,7 +7,7 @@ import {
7
7
  SDK_NODE_NAME,
8
8
  type StudioResource,
9
9
  } from '@sanity/message-protocol'
10
- import {type FrameMessage} from '@sanity/sdk'
10
+ import {type DocumentHandle, type FrameMessage} from '@sanity/sdk'
11
11
  import {useCallback, useState} from 'react'
12
12
 
13
13
  import {useWindowConnection} from './useWindowConnection'
@@ -20,9 +20,7 @@ interface DocumentInteractionHistory {
20
20
  /**
21
21
  * @public
22
22
  */
23
- interface UseRecordDocumentHistoryEventProps {
24
- documentId: string
25
- documentType: string
23
+ interface UseRecordDocumentHistoryEventProps extends DocumentHandle {
26
24
  resourceType: StudioResource['type'] | MediaResource['type'] | CanvasResource['type']
27
25
  resourceId?: string
28
26
  }
@@ -82,10 +80,14 @@ export function useRecordDocumentHistoryEvent({
82
80
  type: 'dashboard/v1/events/history',
83
81
  data: {
84
82
  eventType,
85
- documentId,
86
- documentType,
87
- resourceType,
88
- resourceId: resourceId!,
83
+ document: {
84
+ id: documentId,
85
+ type: documentType,
86
+ resource: {
87
+ id: resourceId!,
88
+ type: resourceType,
89
+ },
90
+ },
89
91
  },
90
92
  }
91
93
 
@@ -1,31 +1,173 @@
1
- import {createSanityInstance} from '@sanity/sdk'
1
+ import {createSanityInstance, type SanityConfig, type SanityInstance} from '@sanity/sdk'
2
2
  import {renderHook} from '@testing-library/react'
3
- import React from 'react'
4
- import {describe, expect, it, vi} from 'vitest'
3
+ import {type ReactNode} from 'react'
4
+ import {describe, expect, it} from 'vitest'
5
5
 
6
- import {SanityProvider} from '../../context/SanityProvider'
6
+ import {SanityInstanceContext} from '../../context/SanityInstanceContext'
7
7
  import {useSanityInstance} from './useSanityInstance'
8
8
 
9
9
  describe('useSanityInstance', () => {
10
- const sanityInstance = createSanityInstance({projectId: 'test-project', dataset: 'production'})
10
+ function createWrapper(instance: SanityInstance | null) {
11
+ return function Wrapper({children}: {children: ReactNode}) {
12
+ return (
13
+ <SanityInstanceContext.Provider value={instance}>{children}</SanityInstanceContext.Provider>
14
+ )
15
+ }
16
+ }
11
17
 
12
- it('returns sanity instance when used within provider', () => {
13
- const wrapper = ({children}: {children: React.ReactNode}) => (
14
- <SanityProvider sanityInstances={[sanityInstance]}>{children}</SanityProvider>
18
+ it('should return the Sanity instance from context', () => {
19
+ // Create a Sanity instance
20
+ const instance = createSanityInstance({projectId: 'test-project', dataset: 'test-dataset'})
21
+
22
+ // Render the hook with the wrapper that provides the context
23
+ const {result} = renderHook(() => useSanityInstance(), {
24
+ wrapper: createWrapper(instance),
25
+ })
26
+
27
+ // Check that the correct instance is returned
28
+ expect(result.current).toBe(instance)
29
+ })
30
+
31
+ it('should throw an error if no instance is found in context', () => {
32
+ // Expect the hook to throw when no instance is in context
33
+ expect(() => {
34
+ renderHook(() => useSanityInstance(), {
35
+ wrapper: createWrapper(null),
36
+ })
37
+ }).toThrow('SanityInstance context not found')
38
+ })
39
+
40
+ it('should include the requested config in error message when no instance found', () => {
41
+ const requestedConfig = {projectId: 'test', dataset: 'test'}
42
+
43
+ // Expect the hook to throw and include the requested config in the error
44
+ expect(() => {
45
+ renderHook(() => useSanityInstance(requestedConfig), {
46
+ wrapper: createWrapper(null),
47
+ })
48
+ }).toThrow(JSON.stringify(requestedConfig, null, 2))
49
+ })
50
+
51
+ it('should find a matching instance with provided config', () => {
52
+ // Create a parent instance
53
+ const parentInstance = createSanityInstance({
54
+ projectId: 'parent-project',
55
+ dataset: 'parent-dataset',
56
+ })
57
+
58
+ // Create a child instance
59
+ const childInstance = parentInstance.createChild({dataset: 'child-dataset'})
60
+
61
+ // Render the hook with the child instance and request the parent config
62
+ const {result} = renderHook(
63
+ () => useSanityInstance({projectId: 'parent-project', dataset: 'parent-dataset'}),
64
+ {wrapper: createWrapper(childInstance)},
15
65
  )
16
66
 
17
- const {result} = renderHook(() => useSanityInstance(), {wrapper})
67
+ // Should match and return the parent instance
68
+ expect(result.current).toBe(parentInstance)
69
+ })
70
+
71
+ it('should throw an error if no matching instance is found for config', () => {
72
+ // Create an instance
73
+ const instance = createSanityInstance({projectId: 'test-project', dataset: 'test-dataset'})
18
74
 
19
- expect(result.current).toBe(sanityInstance)
75
+ // Request a config that doesn't match
76
+ const requestedConfig: SanityConfig = {
77
+ projectId: 'non-existent',
78
+ dataset: 'not-found',
79
+ }
80
+
81
+ // Expect the hook to throw for a non-matching config
82
+ expect(() => {
83
+ renderHook(() => useSanityInstance(requestedConfig), {
84
+ wrapper: createWrapper(instance),
85
+ })
86
+ }).toThrow('Could not find a matching Sanity instance')
20
87
  })
21
88
 
22
- it('throws error when used outside provider', () => {
23
- const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
89
+ it('should include the requested config in error message when no matching instance', () => {
90
+ const instance = createSanityInstance({projectId: 'test-project', dataset: 'test-dataset'})
91
+ const requestedConfig = {projectId: 'different', dataset: 'different'}
24
92
 
93
+ // Expect the error to include the requested config details
25
94
  expect(() => {
26
- renderHook(() => useSanityInstance())
27
- }).toThrow('useSanityInstance must be called from within the SanityProvider')
95
+ renderHook(() => useSanityInstance(requestedConfig), {
96
+ wrapper: createWrapper(instance),
97
+ })
98
+ }).toThrow(JSON.stringify(requestedConfig, null, 2))
99
+ })
100
+
101
+ it('should return the current instance when no config is provided', () => {
102
+ // Create a hierarchy of instances
103
+ const grandparent = createSanityInstance({projectId: 'gp', dataset: 'gp-ds'})
104
+ const parent = grandparent.createChild({projectId: 'p'})
105
+ const child = parent.createChild({dataset: 'child-ds'})
106
+
107
+ // Render the hook with the child instance and no config
108
+ const {result} = renderHook(() => useSanityInstance(), {
109
+ wrapper: createWrapper(child),
110
+ })
111
+
112
+ // Should return the child instance
113
+ expect(result.current).toBe(child)
114
+ })
115
+
116
+ it('should match child instance when it satisfies the config', () => {
117
+ // Create a parent instance
118
+ const parent = createSanityInstance({projectId: 'parent', dataset: 'parent-ds'})
119
+
120
+ // Create a child instance that inherits projectId
121
+ const child = parent.createChild({dataset: 'child-ds'})
122
+
123
+ // Render the hook with the child instance and request by the child's dataset
124
+ const {result} = renderHook(() => useSanityInstance({dataset: 'child-ds'}), {
125
+ wrapper: createWrapper(child),
126
+ })
127
+
128
+ // Should match and return the child instance
129
+ expect(result.current).toBe(child)
130
+ })
131
+
132
+ it('should match partial config correctly', () => {
133
+ // Create an instance with multiple config values
134
+ const instance = createSanityInstance({
135
+ projectId: 'test-proj',
136
+ dataset: 'test-ds',
137
+ })
138
+
139
+ // Should match when requesting just one property
140
+ const {result} = renderHook(() => useSanityInstance({dataset: 'test-ds'}), {
141
+ wrapper: createWrapper(instance),
142
+ })
143
+
144
+ expect(result.current).toBe(instance)
145
+ })
146
+
147
+ it("should match deeper in hierarchy when current instance doesn't match", () => {
148
+ // Create a three-level hierarchy
149
+ const root = createSanityInstance({projectId: 'root', dataset: 'root-ds'})
150
+ const middle = root.createChild({projectId: 'middle'})
151
+ const leaf = middle.createChild({dataset: 'leaf-ds'})
152
+
153
+ // Request config matching the root from the leaf
154
+ const {result} = renderHook(() => useSanityInstance({projectId: 'root', dataset: 'root-ds'}), {
155
+ wrapper: createWrapper(leaf),
156
+ })
157
+
158
+ // Should find and return the root instance
159
+ expect(result.current).toBe(root)
160
+ })
161
+
162
+ it('should match undefined values in config', () => {
163
+ // Create instance with only projectId
164
+ const rootInstance = createSanityInstance({projectId: 'test'})
165
+
166
+ // Match specifically looking for undefined dataset
167
+ const {result} = renderHook(() => useSanityInstance({dataset: undefined}), {
168
+ wrapper: createWrapper(rootInstance),
169
+ })
28
170
 
29
- consoleSpy.mockRestore()
171
+ expect(result.current).toBe(rootInstance)
30
172
  })
31
173
  })
@@ -1,39 +1,79 @@
1
- import {type SanityInstance} from '@sanity/sdk'
2
- import {useContext} from 'react'
1
+ import {type SanityConfig, type SanityInstance} from '@sanity/sdk'
2
+ import {use} from 'react'
3
3
 
4
4
  import {SanityInstanceContext} from '../../context/SanityInstanceContext'
5
5
 
6
6
  /**
7
- * `useSanityInstance` returns the current Sanity instance from the application context.
8
- * This must be called from within a `SanityProvider` component.
9
- * @internal
7
+ * Retrieves the current Sanity instance or finds a matching instance from the hierarchy
10
8
  *
11
- * @param resourceId - The resourceId of the Sanity instance to return (optional)
12
- * @returns The current Sanity instance
13
- * @example
9
+ * @public
10
+ *
11
+ * @param config - Optional configuration to match against when finding an instance
12
+ * @returns The current or matching Sanity instance
13
+ *
14
+ * @remarks
15
+ * This hook accesses the nearest Sanity instance from the React context. When provided with
16
+ * a configuration object, it traverses up the instance hierarchy to find the closest instance
17
+ * that matches the specified configuration using shallow comparison of properties.
18
+ *
19
+ * The hook must be used within a component wrapped by a `ResourceProvider` or `SanityApp`.
20
+ *
21
+ * Use this hook when you need to:
22
+ * - Access the current SanityInstance from context
23
+ * - Find a specific instance with matching project/dataset configuration
24
+ * - Access a parent instance with specific configuration values
25
+ *
26
+ * @example Get the current instance
27
+ * ```tsx
28
+ * // Get the current instance from context
29
+ * const instance = useSanityInstance()
30
+ * console.log(instance.config.projectId)
31
+ * ```
32
+ *
33
+ * @example Find an instance with specific configuration
34
+ * ```tsx
35
+ * // Find an instance matching the given project and dataset
36
+ * const instance = useSanityInstance({
37
+ * projectId: 'abc123',
38
+ * dataset: 'production'
39
+ * })
40
+ *
41
+ * // Use instance for API calls
42
+ * const fetchDocument = (docId) => {
43
+ * // Instance is guaranteed to have the matching config
44
+ * return client.fetch(`*[_id == $id][0]`, { id: docId })
45
+ * }
46
+ * ```
47
+ *
48
+ * @example Match partial configuration
14
49
  * ```tsx
15
- * const instance = useSanityInstance('abc123.production')
50
+ * // Find an instance with specific auth configuration
51
+ * const instance = useSanityInstance({
52
+ * auth: { requireLogin: true }
53
+ * })
16
54
  * ```
55
+ *
56
+ * @throws Error if no SanityInstance is found in context
57
+ * @throws Error if no matching instance is found for the provided config
17
58
  */
18
- export const useSanityInstance = (resourceId?: string): SanityInstance => {
19
- const sanityInstance = useContext(SanityInstanceContext)
20
- if (!sanityInstance) {
21
- throw new Error('useSanityInstance must be called from within the SanityProvider')
22
- }
23
- if (sanityInstance.length === 0) {
24
- throw new Error('No Sanity instances found')
25
- }
26
- if (sanityInstance.length === 1 || !resourceId) {
27
- return sanityInstance[0]
28
- }
59
+ export const useSanityInstance = (config?: SanityConfig): SanityInstance => {
60
+ const instance = use(SanityInstanceContext)
29
61
 
30
- if (!resourceId) {
31
- throw new Error('resourceId is required when there are multiple Sanity instances')
62
+ if (!instance) {
63
+ throw new Error(
64
+ `SanityInstance context not found. ${config ? `Requested config: ${JSON.stringify(config, null, 2)}. ` : ''}Please ensure that your component is wrapped in a <ResourceProvider> or a <SanityApp>.`,
65
+ )
32
66
  }
33
67
 
34
- const instance = sanityInstance.find((inst) => inst.identity.resourceId === resourceId)
35
- if (!instance) {
36
- throw new Error(`Sanity instance with resourceId ${resourceId} not found`)
68
+ if (!config) return instance
69
+
70
+ const match = instance.match(config)
71
+ if (!match) {
72
+ throw new Error(
73
+ `Could not find a matching Sanity instance for the requested configuration: ${JSON.stringify(config, null, 2)}.
74
+ Please ensure there is a <ResourceProvider> with a matching configuration in the component hierarchy.`,
75
+ )
37
76
  }
38
- return instance
77
+
78
+ return match
39
79
  }