@sanity/sdk-react 2.9.0 → 2.11.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 (72) hide show
  1. package/dist/index.d.ts +338 -215
  2. package/dist/index.js +564 -342
  3. package/dist/index.js.map +1 -1
  4. package/package.json +9 -14
  5. package/src/_exports/index.ts +2 -0
  6. package/src/_exports/sdk-react.ts +8 -0
  7. package/src/components/SDKProvider.test.tsx +5 -12
  8. package/src/components/SDKProvider.tsx +58 -28
  9. package/src/components/SanityApp.tsx +2 -2
  10. package/src/components/auth/AuthBoundary.tsx +8 -1
  11. package/src/components/auth/DashboardAccessRequest.tsx +37 -0
  12. package/src/components/auth/LoginError.test.tsx +191 -5
  13. package/src/components/auth/LoginError.tsx +100 -56
  14. package/src/components/errors/ChunkLoadError.test.tsx +59 -0
  15. package/src/components/errors/ChunkLoadError.tsx +56 -0
  16. package/src/components/errors/chunkReloadStorage.ts +57 -0
  17. package/src/config/handles.ts +55 -0
  18. package/src/constants.ts +5 -0
  19. package/src/context/DefaultResourceContext.ts +10 -0
  20. package/src/context/PerspectiveContext.ts +12 -0
  21. package/src/context/ResourceProvider.test.tsx +2 -2
  22. package/src/context/ResourceProvider.tsx +56 -51
  23. package/src/context/ResourcesContext.tsx +7 -0
  24. package/src/context/SanityInstanceProvider.test.tsx +100 -0
  25. package/src/context/SanityInstanceProvider.tsx +71 -0
  26. package/src/hooks/agent/agentActions.ts +55 -38
  27. package/src/hooks/auth/useVerifyOrgProjects.tsx +13 -6
  28. package/src/hooks/context/useResource.test.tsx +32 -0
  29. package/src/hooks/context/useResource.ts +24 -0
  30. package/src/hooks/context/useSanityInstance.test.tsx +42 -111
  31. package/src/hooks/context/useSanityInstance.ts +28 -50
  32. package/src/hooks/dashboard/useDispatchIntent.test.ts +11 -7
  33. package/src/hooks/dashboard/useDispatchIntent.ts +7 -7
  34. package/src/hooks/dashboard/useManageFavorite.test.tsx +16 -12
  35. package/src/hooks/dashboard/utils/useResourceIdFromDocumentHandle.test.ts +15 -15
  36. package/src/hooks/dashboard/utils/useResourceIdFromDocumentHandle.ts +13 -17
  37. package/src/hooks/document/{useApplyDocumentActions.test.ts → useApplyDocumentActions.test.tsx} +46 -81
  38. package/src/hooks/document/useApplyDocumentActions.ts +33 -67
  39. package/src/hooks/document/useDocument.ts +4 -6
  40. package/src/hooks/document/useDocumentEvent.ts +8 -7
  41. package/src/hooks/document/useDocumentPermissions.test.tsx +60 -152
  42. package/src/hooks/document/useDocumentPermissions.ts +78 -55
  43. package/src/hooks/document/useDocumentSyncStatus.ts +2 -2
  44. package/src/hooks/document/useEditDocument.test.tsx +25 -60
  45. package/src/hooks/document/useEditDocument.ts +3 -3
  46. package/src/hooks/documents/useDocuments.ts +19 -11
  47. package/src/hooks/helpers/createStateSourceHook.tsx +1 -2
  48. package/src/hooks/helpers/useNormalizedResourceOptions.test.tsx +253 -0
  49. package/src/hooks/helpers/useNormalizedResourceOptions.ts +169 -0
  50. package/src/hooks/helpers/useTrackHookUsage.ts +2 -2
  51. package/src/hooks/organizations/useOrganization.test-d.ts +53 -0
  52. package/src/hooks/organizations/useOrganization.test.ts +65 -0
  53. package/src/hooks/organizations/useOrganization.ts +40 -0
  54. package/src/hooks/organizations/useOrganizations.test-d.ts +55 -0
  55. package/src/hooks/organizations/useOrganizations.test.ts +85 -0
  56. package/src/hooks/organizations/useOrganizations.ts +45 -0
  57. package/src/hooks/paginatedDocuments/usePaginatedDocuments.ts +29 -14
  58. package/src/hooks/presence/usePresence.test.tsx +56 -9
  59. package/src/hooks/presence/usePresence.ts +16 -4
  60. package/src/hooks/preview/useDocumentPreview.tsx +8 -10
  61. package/src/hooks/projection/useDocumentProjection.ts +7 -9
  62. package/src/hooks/projects/useProject.test-d.ts +49 -0
  63. package/src/hooks/projects/useProject.ts +33 -41
  64. package/src/hooks/projects/useProjects.test-d.ts +49 -0
  65. package/src/hooks/projects/useProjects.ts +17 -23
  66. package/src/hooks/query/useQuery.ts +11 -10
  67. package/src/hooks/releases/useActiveReleases.ts +14 -14
  68. package/src/hooks/releases/usePerspective.ts +11 -16
  69. package/src/hooks/users/useUser.ts +1 -1
  70. package/src/hooks/users/useUsers.ts +1 -1
  71. package/src/context/SourcesContext.tsx +0 -7
  72. package/src/hooks/helpers/useNormalizedSourceOptions.ts +0 -107
@@ -1,21 +1,21 @@
1
1
  import {ClientError} from '@sanity/client'
2
- import {SDK_CHANNEL_NAME, SDK_NODE_NAME} from '@sanity/message-protocol'
3
2
  import {
4
3
  AuthStateType,
5
4
  getClientErrorApiBody,
6
5
  getClientErrorApiDescription,
6
+ getIsInDashboardState,
7
7
  isProjectUserNotFoundClientError,
8
8
  } from '@sanity/sdk'
9
- import {useCallback, useEffect, useState} from 'react'
9
+ import {Suspense, useCallback, useEffect, useMemo, useRef} from 'react'
10
10
  import {type FallbackProps} from 'react-error-boundary'
11
11
 
12
12
  import {useAuthState} from '../../hooks/auth/useAuthState'
13
13
  import {useLogOut} from '../../hooks/auth/useLogOut'
14
- import {useWindowConnection} from '../../hooks/comlink/useWindowConnection'
15
14
  import {useSanityInstance} from '../../hooks/context/useSanityInstance'
16
15
  import {Error} from '../errors/Error'
17
16
  import {AuthError} from './AuthError'
18
17
  import {ConfigurationError} from './ConfigurationError'
18
+ import {DashboardAccessRequest} from './DashboardAccessRequest'
19
19
  /**
20
20
  * @alpha
21
21
  */
@@ -39,75 +39,119 @@ export function LoginError({error, resetErrorBoundary}: LoginErrorProps): React.
39
39
 
40
40
  const logout = useLogOut()
41
41
  const authState = useAuthState()
42
+ const instance = useSanityInstance()
42
43
  const {
43
44
  config: {projectId},
44
- } = useSanityInstance()
45
+ } = instance
45
46
 
46
- const [authErrorMessage, setAuthErrorMessage] = useState(
47
- 'Please try again or contact support if the problem persists.',
48
- )
49
- const [showRetryCta, setShowRetryCta] = useState(true)
47
+ // Errors surfaced through `AuthBoundary` arrive wrapped in `AuthError`, with
48
+ // the original `ClientError` tucked under `.cause`. Unwrapping it here lets
49
+ // the 401/404 branches below respond to the real status code instead of
50
+ // silently skipping because `error instanceof ClientError` is false.
51
+ const clientError: ClientError | null =
52
+ error instanceof ClientError
53
+ ? error
54
+ : error instanceof AuthError && error.cause instanceof ClientError
55
+ ? error.cause
56
+ : null
57
+
58
+ const isInDashboard = getIsInDashboardState(instance).getCurrent()
50
59
 
51
- /**
52
- * TODO: before merge update message-protocol package to include the new message type
53
- */
54
- const {fetch} = useWindowConnection({
55
- name: SDK_NODE_NAME,
56
- connectTo: SDK_CHANNEL_NAME,
57
- })
60
+ const isProjectUserNotFound =
61
+ !!clientError && clientError.statusCode === 401 && isProjectUserNotFoundClientError(clientError)
62
+
63
+ // The dashboard access request flow relies on a comlink connection to the
64
+ // parent window. In standalone apps that connection never materializes, so
65
+ // we must skip it entirely to avoid suspending forever on the parent's
66
+ // Suspense boundary. Resolving to the projectId (or null) here lets the JSX
67
+ // render the child with a single non-null guard.
68
+ const dashboardAccessProjectId =
69
+ isProjectUserNotFound && projectId && isInDashboard ? projectId : null
58
70
 
59
71
  const handleRetry = useCallback(async () => {
60
72
  await logout()
61
73
  resetErrorBoundary()
62
74
  }, [logout, resetErrorBoundary])
63
75
 
64
- useEffect(() => {
65
- if (error instanceof ClientError) {
66
- if (error.statusCode === 401) {
67
- // Surface a friendly message for projectUserNotFoundError (do not logout/refresh)
68
- if (isProjectUserNotFoundClientError(error)) {
69
- const description = getClientErrorApiDescription(error)
70
- if (description) setAuthErrorMessage(description)
71
- setShowRetryCta(false)
72
- /**
73
- * Handoff to dashboard to enable the request access flow for the project.
74
- */
75
- fetch('dashboard/v1/auth/access/request', {
76
- resourceType: 'project',
77
- resourceId: projectId,
78
- })
79
- } else {
80
- setShowRetryCta(true)
81
- handleRetry()
82
- }
83
- } else if (error.statusCode === 404) {
84
- const errorMessage = getClientErrorApiBody(error)?.message || ''
85
- if (errorMessage.startsWith('Session with sid') && errorMessage.endsWith('not found')) {
86
- setAuthErrorMessage('The session ID is invalid or expired.')
87
- } else {
88
- setAuthErrorMessage('The login link is invalid or expired. Please try again.')
76
+ // Display state is fully derived from the inputs above, so we don't need
77
+ // to mirror it through useState/useEffect.
78
+ const {authErrorMessage, showRetryCta} = useMemo(() => {
79
+ let message = 'Please try again or contact support if the problem persists.'
80
+ let retry = true
81
+
82
+ if (clientError) {
83
+ if (clientError.statusCode === 401) {
84
+ if (isProjectUserNotFound) {
85
+ const description = getClientErrorApiDescription(clientError)
86
+ if (description) message = description
87
+ retry = false
88
+ } else if (!isInDashboard) {
89
+ message = 'Signing you out and returning to login...'
90
+ retry = true
89
91
  }
90
- setShowRetryCta(true)
92
+ // Dashboard non-projectUserNotFound 401: leave the current UI in place
93
+ // and let ComlinkTokenRefreshProvider request a fresh token from the
94
+ // parent window. The Retry button remains as a manual fallback.
95
+ } else if (clientError.statusCode === 404) {
96
+ const errorMessage = getClientErrorApiBody(clientError)?.message || ''
97
+ message =
98
+ errorMessage.startsWith('Session with sid') && errorMessage.endsWith('not found')
99
+ ? 'The session ID is invalid or expired.'
100
+ : 'The login link is invalid or expired. Please try again.'
101
+ retry = true
91
102
  }
92
103
  }
93
104
  if (authState.type !== AuthStateType.ERROR && error instanceof ConfigurationError) {
94
- setAuthErrorMessage(error.message)
95
- setShowRetryCta(true)
105
+ message = error.message
106
+ retry = true
96
107
  }
97
- }, [authState, handleRetry, error, fetch, projectId])
108
+ return {authErrorMessage: message, showRetryCta: retry}
109
+ }, [authState, clientError, error, isInDashboard, isProjectUserNotFound])
110
+
111
+ // Guards against re-entering the standalone auto-logout branch below. Once
112
+ // `logout()` flips the auth store to LOGGED_OUT, `useAuthState` emits a new
113
+ // `authState` reference and re-runs this effect; without the ref we'd call
114
+ // `handleRetry` again on every emission and React eventually aborts with
115
+ // "Maximum update depth exceeded", leaving a blank page.
116
+ const hasAutoLoggedOutRef = useRef(false)
117
+
118
+ // Standalone apps: the token is bad and there's no parent window to mint a
119
+ // new one, so log the user out and let `AuthBoundary`'s LOGGED_OUT effect
120
+ // redirect to the Sanity login URL.
121
+ useEffect(() => {
122
+ if (
123
+ clientError &&
124
+ clientError.statusCode === 401 &&
125
+ !isProjectUserNotFound &&
126
+ !isInDashboard &&
127
+ !hasAutoLoggedOutRef.current
128
+ ) {
129
+ hasAutoLoggedOutRef.current = true
130
+ handleRetry()
131
+ }
132
+ }, [clientError, handleRetry, isInDashboard, isProjectUserNotFound])
98
133
 
99
134
  return (
100
- <Error
101
- heading={error instanceof AuthError ? 'Authentication Error' : 'Configuration Error'}
102
- description={authErrorMessage}
103
- cta={
104
- showRetryCta
105
- ? {
106
- text: 'Retry',
107
- onClick: handleRetry,
108
- }
109
- : undefined
110
- }
111
- />
135
+ <>
136
+ {dashboardAccessProjectId && (
137
+ <Suspense fallback={null}>
138
+ <DashboardAccessRequest projectId={dashboardAccessProjectId} />
139
+ </Suspense>
140
+ )}
141
+ <Error
142
+ heading={
143
+ error instanceof ConfigurationError ? 'Configuration Error' : 'Authentication Error'
144
+ }
145
+ description={authErrorMessage}
146
+ cta={
147
+ showRetryCta
148
+ ? {
149
+ text: 'Retry',
150
+ onClick: handleRetry,
151
+ }
152
+ : undefined
153
+ }
154
+ />
155
+ </>
112
156
  )
113
157
  }
@@ -0,0 +1,59 @@
1
+ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
2
+
3
+ import {render, screen} from '../../../test/test-utils'
4
+ import {ChunkLoadError} from './ChunkLoadError'
5
+ import {CHUNK_RELOAD_STORAGE_KEY} from './chunkReloadStorage'
6
+
7
+ const noop = (): void => {}
8
+
9
+ describe('ChunkLoadError', () => {
10
+ const reloadSpy = vi.fn()
11
+ const originalLocation = window.location
12
+
13
+ beforeEach(() => {
14
+ reloadSpy.mockReset()
15
+ window.sessionStorage.clear()
16
+ Object.defineProperty(window, 'location', {
17
+ configurable: true,
18
+ value: {...originalLocation, reload: reloadSpy},
19
+ })
20
+ })
21
+
22
+ afterEach(() => {
23
+ Object.defineProperty(window, 'location', {configurable: true, value: originalLocation})
24
+ window.sessionStorage.clear()
25
+ })
26
+
27
+ it('triggers an automatic reload and renders nothing on the first occurrence', () => {
28
+ render(
29
+ <ChunkLoadError
30
+ error={new Error('Failed to fetch dynamically imported module')}
31
+ resetErrorBoundary={noop}
32
+ />,
33
+ )
34
+
35
+ expect(reloadSpy).toHaveBeenCalledTimes(1)
36
+ expect(window.sessionStorage.getItem(CHUNK_RELOAD_STORAGE_KEY)).toBe('1')
37
+ expect(screen.queryByText(/new version/i)).toBeNull()
38
+ })
39
+
40
+ it('renders the manual reload UI when the flag is already set', () => {
41
+ window.sessionStorage.setItem(CHUNK_RELOAD_STORAGE_KEY, '1')
42
+
43
+ render(
44
+ <ChunkLoadError
45
+ error={new Error('Failed to fetch dynamically imported module')}
46
+ resetErrorBoundary={noop}
47
+ />,
48
+ )
49
+
50
+ expect(reloadSpy).not.toHaveBeenCalled()
51
+ expect(screen.getByText('A new version is available')).toBeInTheDocument()
52
+
53
+ const button = screen.getByRole('button', {name: 'Reload page'})
54
+ button.click()
55
+
56
+ expect(reloadSpy).toHaveBeenCalledTimes(1)
57
+ expect(window.sessionStorage.getItem(CHUNK_RELOAD_STORAGE_KEY)).toBeNull()
58
+ })
59
+ })
@@ -0,0 +1,56 @@
1
+ import {useEffect} from 'react'
2
+ import {type FallbackProps} from 'react-error-boundary'
3
+
4
+ import {clearChunkReloadFlag, readChunkReloadFlag, setChunkReloadFlag} from './chunkReloadStorage'
5
+ import {Error} from './Error'
6
+
7
+ function reload(): void {
8
+ try {
9
+ window.location.reload()
10
+ } catch {
11
+ // No-op: nothing useful we can do if reload itself throws.
12
+ }
13
+ }
14
+
15
+ /**
16
+ * Default fallback rendered when a dynamic-import or chunk-load error
17
+ * bubbles up to the SDK's top-level error boundary.
18
+ *
19
+ * On the first occurrence in a session we set a flag and trigger
20
+ * window.location.reload(), since chunk-load errors almost always indicate a
21
+ * stale tab that simply needs a fresh index.html. If the flag is already set
22
+ * we render a manual reload UI instead, which prevents an infinite reload
23
+ * loop in the rare case the error is genuinely unrecoverable (network
24
+ * outage, CSP, etc.).
25
+ *
26
+ * @internal
27
+ */
28
+ export function ChunkLoadError(_props: FallbackProps): React.ReactNode {
29
+ const alreadyAttempted = readChunkReloadFlag()
30
+
31
+ useEffect(() => {
32
+ if (alreadyAttempted) return
33
+ setChunkReloadFlag()
34
+ reload()
35
+ }, [alreadyAttempted])
36
+
37
+ if (!alreadyAttempted) {
38
+ // Render nothing during the brief window before the page reloads so the
39
+ // user does not see a flash of error UI.
40
+ return null
41
+ }
42
+
43
+ return (
44
+ <Error
45
+ heading="A new version is available"
46
+ description="The page tried to load an asset that no longer exists. Reload to continue with the latest version."
47
+ cta={{
48
+ text: 'Reload page',
49
+ onClick: () => {
50
+ clearChunkReloadFlag()
51
+ reload()
52
+ },
53
+ }}
54
+ />
55
+ )
56
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Session-storage key tracking whether the SDK has already attempted an
3
+ * automatic reload in response to a chunk-load error during this session.
4
+ *
5
+ * @internal
6
+ */
7
+ export const CHUNK_RELOAD_STORAGE_KEY = '__sanity_sdk_chunk_reload_attempted'
8
+
9
+ /**
10
+ * Returns true when this session has already triggered an automatic reload.
11
+ * Returns false if session storage is unreadable.
12
+ *
13
+ * @internal
14
+ */
15
+ export function readChunkReloadFlag(): boolean {
16
+ try {
17
+ if (typeof window === 'undefined' || typeof window.sessionStorage === 'undefined') {
18
+ return false
19
+ }
20
+ return window.sessionStorage.getItem(CHUNK_RELOAD_STORAGE_KEY) !== null
21
+ } catch {
22
+ return false
23
+ }
24
+ }
25
+
26
+ /**
27
+ * Marks the session as having attempted an automatic reload, so the next
28
+ * chunk-load error renders the manual reload UI instead of looping.
29
+ *
30
+ * @internal
31
+ */
32
+ export function setChunkReloadFlag(): void {
33
+ try {
34
+ if (typeof window === 'undefined' || typeof window.sessionStorage === 'undefined') return
35
+ window.sessionStorage.setItem(CHUNK_RELOAD_STORAGE_KEY, '1')
36
+ } catch {
37
+ // Storage may be unavailable (private mode quotas, disabled cookies).
38
+ // Falling through means the user sees the manual-reload UI instead of an
39
+ // automatic reload, which is the correct degradation.
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Clears the chunk-reload flag. Called from SDKProvider once the SDK
45
+ * mounts successfully past the error boundary so a future incident in the
46
+ * same session can trigger another automatic reload.
47
+ *
48
+ * @internal
49
+ */
50
+ export function clearChunkReloadFlag(): void {
51
+ try {
52
+ if (typeof window === 'undefined' || typeof window.sessionStorage === 'undefined') return
53
+ window.sessionStorage.removeItem(CHUNK_RELOAD_STORAGE_KEY)
54
+ } catch {
55
+ // No-op: see setChunkReloadFlag.
56
+ }
57
+ }
@@ -0,0 +1,55 @@
1
+ import {
2
+ type DatasetHandle,
3
+ type DocumentHandle as CoreDocumentHandle,
4
+ type DocumentTypeHandle as CoreDocumentTypeHandle,
5
+ } from '@sanity/sdk'
6
+
7
+ /**
8
+ * React SDK resource handle — extends the core DatasetHandle with `resourceName`
9
+ * for context-based resource resolution.
10
+ *
11
+ * Use this (or its subtypes) as the options type for custom hooks that need to
12
+ * accept a resource. It accepts a `resource` object, a `resourceName` registered
13
+ * via the `resources` prop on `<SanityApp>`, or a bare `projectId`/`dataset` pair
14
+ * for backward compatibility.
15
+ *
16
+ * @public
17
+ */
18
+ export interface ResourceHandle<
19
+ TDataset extends string = string,
20
+ TProjectId extends string = string,
21
+ > extends DatasetHandle<TDataset, TProjectId> {
22
+ /**
23
+ * Name of a resource registered via the `resources` prop on `<SanityApp>`.
24
+ * Resolved to a `DocumentResource` at the React layer.
25
+ */
26
+ resourceName?: string
27
+ }
28
+
29
+ /**
30
+ * React SDK document-type handle. Adds `resourceName` to the core `DocumentTypeHandle`.
31
+ * @public
32
+ */
33
+ export interface DocumentTypeHandle<
34
+ TDocumentType extends string = string,
35
+ TDataset extends string = string,
36
+ TProjectId extends string = string,
37
+ > extends CoreDocumentTypeHandle<TDocumentType, TDataset, TProjectId> {
38
+ resourceName?: string
39
+ }
40
+
41
+ /**
42
+ * React SDK document handle. Adds `resourceName` to the core `DocumentHandle`.
43
+ *
44
+ * Import from `@sanity/sdk-react` (not `@sanity/sdk`) when writing option types
45
+ * for hooks — this version understands `resourceName` resolution.
46
+ *
47
+ * @public
48
+ */
49
+ export interface DocumentHandle<
50
+ TDocumentType extends string = string,
51
+ TDataset extends string = string,
52
+ TProjectId extends string = string,
53
+ > extends CoreDocumentHandle<TDocumentType, TDataset, TProjectId> {
54
+ resourceName?: string
55
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * The name of the resource automatically registered from a single-config `<SanityApp>`.
3
+ * Hooks that receive no resource information fall back to this named resource.
4
+ */
5
+ export const DEFAULT_RESOURCE_NAME = 'default'
@@ -0,0 +1,10 @@
1
+ import {type DocumentResource} from '@sanity/sdk'
2
+ import {createContext} from 'react'
3
+
4
+ /**
5
+ * Provides the active DocumentResource for a subtree.
6
+ * Set by ResourceProvider; read by useNormalizedResourceOptions as a fallback
7
+ * when hooks receive no explicit resource or resourceName.
8
+ * @internal
9
+ */
10
+ export const ResourceContext = createContext<DocumentResource | undefined>(undefined)
@@ -0,0 +1,12 @@
1
+ import {type PerspectiveHandle} from '@sanity/sdk'
2
+ import {createContext} from 'react'
3
+
4
+ /**
5
+ * Provides the active perspective for a subtree.
6
+ * Set by ResourceProvider; injected by useNormalizedResourceOptions when
7
+ * the hook's options don't include an explicit perspective.
8
+ * @internal
9
+ */
10
+ export const PerspectiveContext = createContext<PerspectiveHandle['perspective'] | undefined>(
11
+ undefined,
12
+ )
@@ -78,7 +78,7 @@ describe('ResourceProvider', () => {
78
78
  })
79
79
  })
80
80
 
81
- it('creates child instance when parent context exists', async () => {
81
+ it('reuses instance when parent context exists', async () => {
82
82
  const parentConfig: SanityConfig = {...testConfig, dataset: 'parent-dataset'}
83
83
  const child = promiseWithResolvers<SanityInstance | null>()
84
84
 
@@ -97,7 +97,7 @@ describe('ResourceProvider', () => {
97
97
  )
98
98
 
99
99
  const childInstance = await child.promise
100
- expect(childInstance?.config).toEqual(testConfig)
100
+ expect(childInstance?.config).toEqual(parentConfig)
101
101
  expect(childInstance?.isDisposed()).toBe(false)
102
102
  })
103
103
 
@@ -1,8 +1,18 @@
1
- import {createSanityInstance, type SanityConfig, type SanityInstance} from '@sanity/sdk'
1
+ import {
2
+ createSanityInstance,
3
+ type DatasetResource,
4
+ type DocumentResource,
5
+ isDatasetResource,
6
+ type SanityConfig,
7
+ type SanityInstance,
8
+ } from '@sanity/sdk'
2
9
  import {initTelemetry} from '@sanity/sdk/_internal'
3
- import {Suspense, useContext, useEffect, useMemo, useRef} from 'react'
10
+ import {useContext, useEffect, useMemo, useRef, useState} from 'react'
4
11
 
12
+ import {ResourceContext} from './DefaultResourceContext'
13
+ import {PerspectiveContext} from './PerspectiveContext'
5
14
  import {SanityInstanceContext} from './SanityInstanceContext'
15
+ import {SanityInstanceProvider} from './SanityInstanceProvider'
6
16
 
7
17
  const DEFAULT_FALLBACK = (
8
18
  <>
@@ -16,73 +26,63 @@ const DEFAULT_FALLBACK = (
16
26
  */
17
27
  export interface ResourceProviderProps extends SanityConfig {
18
28
  /**
19
- * React node to show while content is loading
20
- * Used as the fallback for the internal Suspense boundary
29
+ * The document resource (project/dataset, media library, or canvas)
30
+ * for this subtree. Hooks that don't specify an explicit resource will
31
+ * use this value.
32
+ */
33
+ resource?: DocumentResource
34
+ /**
35
+ * React node to show while content is loading.
36
+ * Used as the fallback for the internal Suspense boundary.
21
37
  */
22
38
  fallback: React.ReactNode
23
39
  children: React.ReactNode
24
40
  }
25
41
 
26
42
  /**
27
- * Provides a Sanity instance to child components through React Context
43
+ * Provides Sanity configuration to child components through React Context.
28
44
  *
29
45
  * @internal
30
46
  *
31
- * @remarks
32
- * The ResourceProvider creates a hierarchical structure of Sanity instances:
33
- * - When used as a root provider, it creates a new Sanity instance with the given config
34
- * - When nested inside another ResourceProvider, it creates a child instance that
35
- * inherits and extends the parent's configuration
36
- *
37
- * Features:
38
- * - Automatically manages the lifecycle of Sanity instances
39
- * - Disposes instances when the component unmounts
40
- * - Includes a Suspense boundary for data loading
41
- * - Enables hierarchical configuration inheritance
42
- *
43
- * Use this component to:
44
- * - Set up project/dataset configuration for an application
45
- * - Override specific configuration values in a section of your app
46
- * - Create isolated instance hierarchies for different features
47
- *
48
- * @example Creating a root provider
47
+ * @example
49
48
  * ```tsx
50
49
  * <ResourceProvider
51
- * projectId="your-project-id"
52
- * dataset="production"
50
+ * resource={{ projectId: 'your-project-id', dataset: 'production' }}
53
51
  * fallback={<LoadingSpinner />}
54
52
  * >
55
53
  * <YourApp />
56
54
  * </ResourceProvider>
57
55
  * ```
58
- *
59
- * @example Creating nested providers with configuration inheritance
60
- * ```tsx
61
- * // Root provider with production config with nested provider for preview features with custom dataset
62
- * <ResourceProvider projectId="abc123" dataset="production" fallback={<Loading />}>
63
- * <div>...Main app content</div>
64
- * <Dashboard />
65
- * <ResourceProvider dataset="preview" fallback={<Loading />}>
66
- * <PreviewFeatures />
67
- * </ResourceProvider>
68
- * </ResourceProvider>
69
- * ```
70
56
  */
71
57
  export function ResourceProvider({
72
58
  children,
73
59
  fallback,
60
+ resource,
74
61
  ...config
75
62
  }: ResourceProviderProps): React.ReactNode {
76
- const parent = useContext(SanityInstanceContext)
77
- const instance = useMemo(
78
- () => (parent ? parent.createChild(config) : createSanityInstance(config)),
79
- [config, parent],
80
- )
63
+ const parentPerspective = useContext(PerspectiveContext)
64
+ const parentResource = useContext(ResourceContext)
65
+ const parentInstance = useContext(SanityInstanceContext)
66
+
67
+ const {projectId, dataset, perspective} = config
68
+
69
+ const [instance] = useState<SanityInstance>(() => parentInstance ?? createSanityInstance(config))
81
70
 
82
- const projectId = config.projectId ?? ''
83
- useMemo(() => {
84
- if (projectId && !parent) initTelemetry(instance, projectId)
85
- }, [instance, projectId, parent])
71
+ const configResource: DatasetResource | undefined = useMemo(() => {
72
+ if (projectId && dataset) {
73
+ return {projectId, dataset}
74
+ }
75
+ return undefined
76
+ }, [projectId, dataset])
77
+
78
+ const effectiveResource = useMemo(() => {
79
+ return resource ?? configResource ?? parentResource
80
+ }, [resource, configResource, parentResource])
81
+
82
+ useEffect(() => {
83
+ if (effectiveResource && isDatasetResource(effectiveResource))
84
+ initTelemetry(instance, effectiveResource.projectId)
85
+ }, [instance, effectiveResource])
86
86
 
87
87
  // Ref to hold the scheduled disposal timer.
88
88
  const disposal = useRef<{
@@ -101,17 +101,22 @@ export function ResourceProvider({
101
101
  disposal.current = {
102
102
  instance,
103
103
  timeoutId: setTimeout(() => {
104
- if (!instance.isDisposed()) {
104
+ // don't dispose the parent instance when this unmounts
105
+ if (!instance.isDisposed() && instance !== parentInstance) {
105
106
  instance.dispose()
106
107
  }
107
108
  }, 0),
108
109
  }
109
110
  }
110
- }, [instance])
111
+ }, [instance, parentInstance])
111
112
 
112
113
  return (
113
- <SanityInstanceContext.Provider value={instance}>
114
- <Suspense fallback={fallback ?? DEFAULT_FALLBACK}>{children}</Suspense>
115
- </SanityInstanceContext.Provider>
114
+ <SanityInstanceProvider instance={instance} fallback={fallback ?? DEFAULT_FALLBACK}>
115
+ <ResourceContext.Provider value={effectiveResource}>
116
+ <PerspectiveContext.Provider value={perspective ?? parentPerspective}>
117
+ {children}
118
+ </PerspectiveContext.Provider>
119
+ </ResourceContext.Provider>
120
+ </SanityInstanceProvider>
116
121
  )
117
122
  }
@@ -0,0 +1,7 @@
1
+ import {type DocumentResource} from '@sanity/sdk'
2
+ import {createContext} from 'react'
3
+
4
+ /** Context for resources.
5
+ * @beta
6
+ */
7
+ export const ResourcesContext = createContext<Record<string, DocumentResource>>({})