@sanity/sdk-react 2.7.0 → 3.0.0-rc.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 (88) hide show
  1. package/README.md +125 -63
  2. package/dist/index.d.ts +381 -571
  3. package/dist/index.js +450 -366
  4. package/dist/index.js.map +1 -1
  5. package/package.json +6 -8
  6. package/src/_exports/index.ts +4 -0
  7. package/src/_exports/sdk-react.ts +16 -0
  8. package/src/components/SDKProvider.test.tsx +23 -58
  9. package/src/components/SDKProvider.tsx +38 -30
  10. package/src/components/SanityApp.test.tsx +12 -68
  11. package/src/components/SanityApp.tsx +88 -65
  12. package/src/components/auth/AuthBoundary.test.tsx +11 -26
  13. package/src/components/auth/LoginError.test.tsx +5 -0
  14. package/src/components/auth/LoginError.tsx +23 -2
  15. package/src/config/handles.ts +53 -0
  16. package/src/context/ComlinkTokenRefresh.test.tsx +27 -10
  17. package/src/context/DefaultResourceContext.ts +10 -0
  18. package/src/context/PerspectiveContext.ts +12 -0
  19. package/src/context/ResourceProvider.test.tsx +99 -19
  20. package/src/context/ResourceProvider.tsx +103 -37
  21. package/src/context/ResourcesContext.tsx +7 -0
  22. package/src/context/SDKStudioContext.test.tsx +33 -28
  23. package/src/context/SDKStudioContext.ts +6 -0
  24. package/src/context/renderSanityApp.test.tsx +49 -151
  25. package/src/context/renderSanityApp.tsx +8 -12
  26. package/src/hooks/agent/agentActions.test.tsx +1 -1
  27. package/src/hooks/agent/agentActions.ts +56 -19
  28. package/src/hooks/auth/useDashboardOrganizationId.test.tsx +8 -2
  29. package/src/hooks/auth/useVerifyOrgProjects.test.tsx +32 -8
  30. package/src/hooks/client/useClient.test.tsx +4 -1
  31. package/src/hooks/client/useClient.ts +0 -1
  32. package/src/hooks/context/useDefaultResource.test.tsx +25 -0
  33. package/src/hooks/context/useDefaultResource.ts +30 -0
  34. package/src/hooks/context/useSanityInstance.test.tsx +2 -140
  35. package/src/hooks/context/useSanityInstance.ts +9 -53
  36. package/src/hooks/dashboard/useDispatchIntent.test.ts +24 -15
  37. package/src/hooks/dashboard/useDispatchIntent.ts +7 -7
  38. package/src/hooks/dashboard/useManageFavorite.test.tsx +34 -94
  39. package/src/hooks/dashboard/useManageFavorite.ts +16 -10
  40. package/src/hooks/dashboard/useNavigateToStudioDocument.test.ts +7 -5
  41. package/src/hooks/dashboard/useNavigateToStudioDocument.ts +6 -2
  42. package/src/hooks/dashboard/useRecordDocumentHistoryEvent.test.ts +2 -0
  43. package/src/hooks/dashboard/useRecordDocumentHistoryEvent.ts +2 -1
  44. package/src/hooks/dashboard/utils/useResourceIdFromDocumentHandle.test.ts +17 -38
  45. package/src/hooks/dashboard/utils/useResourceIdFromDocumentHandle.ts +12 -19
  46. package/src/hooks/datasets/useDatasets.test.ts +8 -22
  47. package/src/hooks/datasets/useDatasets.ts +8 -16
  48. package/src/hooks/document/useApplyDocumentActions.test.ts +98 -52
  49. package/src/hooks/document/useApplyDocumentActions.ts +35 -37
  50. package/src/hooks/document/useDocument.test.tsx +8 -37
  51. package/src/hooks/document/useDocument.ts +78 -129
  52. package/src/hooks/document/useDocumentEvent.test.tsx +7 -19
  53. package/src/hooks/document/useDocumentEvent.ts +21 -19
  54. package/src/hooks/document/useDocumentPermissions.test.tsx +75 -84
  55. package/src/hooks/document/useDocumentPermissions.ts +41 -28
  56. package/src/hooks/document/useDocumentSyncStatus.test.ts +13 -3
  57. package/src/hooks/document/useDocumentSyncStatus.ts +19 -14
  58. package/src/hooks/document/useEditDocument.test.tsx +28 -70
  59. package/src/hooks/document/useEditDocument.ts +29 -149
  60. package/src/hooks/documents/useDocuments.test.tsx +44 -64
  61. package/src/hooks/documents/useDocuments.ts +19 -25
  62. package/src/hooks/helpers/createCallbackHook.test.tsx +19 -13
  63. package/src/hooks/helpers/createStateSourceHook.test.tsx +10 -10
  64. package/src/hooks/helpers/createStateSourceHook.tsx +2 -4
  65. package/src/hooks/helpers/useNormalizedResourceOptions.test.ts +65 -0
  66. package/src/hooks/helpers/useNormalizedResourceOptions.ts +127 -0
  67. package/src/hooks/paginatedDocuments/usePaginatedDocuments.test.tsx +27 -34
  68. package/src/hooks/paginatedDocuments/usePaginatedDocuments.ts +19 -20
  69. package/src/hooks/presence/usePresence.test.tsx +71 -9
  70. package/src/hooks/presence/usePresence.ts +28 -3
  71. package/src/hooks/preview/useDocumentPreview.test.tsx +85 -193
  72. package/src/hooks/preview/useDocumentPreview.tsx +42 -62
  73. package/src/hooks/projection/useDocumentProjection.test.tsx +9 -37
  74. package/src/hooks/projection/useDocumentProjection.ts +9 -82
  75. package/src/hooks/projects/useProject.test.ts +1 -2
  76. package/src/hooks/projects/useProject.ts +7 -8
  77. package/src/hooks/query/useQuery.test.tsx +5 -6
  78. package/src/hooks/query/useQuery.ts +12 -91
  79. package/src/hooks/releases/useActiveReleases.test.tsx +2 -2
  80. package/src/hooks/releases/useActiveReleases.ts +25 -13
  81. package/src/hooks/releases/usePerspective.test.tsx +9 -17
  82. package/src/hooks/releases/usePerspective.ts +29 -18
  83. package/src/hooks/users/useUser.test.tsx +9 -3
  84. package/src/hooks/users/useUser.ts +1 -1
  85. package/src/hooks/users/useUsers.test.tsx +5 -2
  86. package/src/hooks/users/useUsers.ts +1 -1
  87. package/src/context/SourcesContext.tsx +0 -7
  88. package/src/hooks/helpers/useNormalizedSourceOptions.ts +0 -85
@@ -1,4 +1,9 @@
1
- import {type DocumentSource, isStudioConfig, type SanityConfig} from '@sanity/sdk'
1
+ import {
2
+ DEFAULT_RESOURCE_NAME,
3
+ type DocumentResource,
4
+ isStudioConfig,
5
+ type SanityConfig,
6
+ } from '@sanity/sdk'
2
7
  import {type ReactElement, useContext, useEffect, useMemo} from 'react'
3
8
 
4
9
  import {SDKStudioContext, type StudioWorkspaceHandle} from '../context/SDKStudioContext'
@@ -11,15 +16,17 @@ import {isInIframe, isLocalUrl} from './utils'
11
16
  */
12
17
  export interface SanityAppProps {
13
18
  /**
14
- * One or more SanityConfig objects providing a project ID and dataset name.
15
- * Optional when `SanityApp` is rendered inside an `SDKStudioContext` provider
16
- * (e.g. inside Sanity Studio) — the config is derived from the workspace
17
- * automatically.
19
+ * Core configuration for the SDK instance (auth, studio, perspective).
20
+ * Optional when `SanityApp` is rendered inside an `SDKStudioContext`
21
+ * provider (e.g. inside Sanity Studio) — the config is derived from
22
+ * the workspace automatically.
18
23
  */
19
- config?: SanityConfig | SanityConfig[]
20
- /** @deprecated use the `config` prop instead. */
21
- sanityConfigs?: SanityConfig[]
22
- sources?: Record<string, DocumentSource>
24
+ config?: SanityConfig
25
+ /**
26
+ * Named document resources for the application. The resource keyed `"default"`
27
+ * is used automatically when no explicit resource is specified in hooks.
28
+ */
29
+ resources?: Record<string, DocumentResource>
23
30
  children: React.ReactNode
24
31
  /* Fallback content to show when child components are suspending. Same as the `fallback` prop for React Suspense. */
25
32
  fallback: React.ReactNode
@@ -28,16 +35,22 @@ export interface SanityAppProps {
28
35
  const REDIRECT_URL = 'https://sanity.io/welcome'
29
36
 
30
37
  /**
31
- * Derive a SanityConfig from a Studio workspace handle.
32
- * Maps the workspace's projectId, dataset, and reactive auth token into
33
- * the SDK's config shape.
38
+ * Derive a SanityConfig and resources map from a Studio workspace handle.
34
39
  */
35
- function deriveConfigFromWorkspace(workspace: StudioWorkspaceHandle): SanityConfig {
40
+ function deriveFromWorkspace(workspace: StudioWorkspaceHandle): {
41
+ config: SanityConfig
42
+ resources: Record<string, DocumentResource>
43
+ } {
36
44
  return {
37
- projectId: workspace.projectId,
38
- dataset: workspace.dataset,
39
- studio: {
40
- auth: workspace.auth.token ? {token: workspace.auth.token} : undefined,
45
+ config: {
46
+ studio: {
47
+ authenticated: workspace.authenticated,
48
+ auth: workspace.auth.token ? {token: workspace.auth.token} : undefined,
49
+ projectId: workspace.projectId,
50
+ },
51
+ },
52
+ resources: {
53
+ [DEFAULT_RESOURCE_NAME]: {projectId: workspace.projectId, dataset: workspace.dataset},
41
54
  },
42
55
  }
43
56
  }
@@ -49,57 +62,49 @@ function deriveConfigFromWorkspace(workspace: StudioWorkspaceHandle): SanityConf
49
62
  * as well as application context and state which is used by the Sanity React hooks. Your application
50
63
  * must be wrapped with the SanityApp component to function properly.
51
64
  *
52
- * The `config` prop on the SanityApp component accepts either a single {@link SanityConfig} object, or an array of them.
53
- * This allows your app to work with one or more of your organization's datasets.
54
- *
55
- * When rendered inside a Sanity Studio that provides `SDKStudioContext`, the `config` prop is
56
- * optional — `SanityApp` will automatically derive `projectId`, `dataset`, and auth from the
57
- * Studio workspace.
65
+ * The `config` prop accepts a {@link SanityConfig} object. Use the `resources` prop to declare
66
+ * one or more named data resources for your app.
58
67
  *
59
- * @remarks
60
- * When passing multiple SanityConfig objects to the `config` prop, the first configuration in the array becomes the default
61
- * configuration used by the App SDK Hooks.
68
+ * When rendered inside a Sanity Studio that provides `SDKStudioContext`, the `config` and `resources`
69
+ * props are optional `SanityApp` will automatically derive them from the Studio workspace.
62
70
  *
63
- * When both `config` and `SDKStudioContext` are available, the explicit `config` takes precedence.
71
+ * When both `config` and `SDKStudioContext` are available, the explicit props take precedence.
64
72
  *
65
73
  * @category Components
66
74
  * @param props - Your Sanity configuration and the React children to render
67
75
  * @returns Your Sanity application, integrated with your Sanity configuration and application context
68
76
  *
69
- * @example
77
+ * @example Single project
70
78
  * ```tsx
71
- * import { SanityApp, type SanityConfig } from '@sanity/sdk-react'
79
+ * import { SanityApp } from '@sanity/sdk-react'
72
80
  *
73
- * import MyAppRoot from './Root'
74
- *
75
- * // Single project configuration
76
- * const mySanityConfig: SanityConfig = {
77
- * projectId: 'my-project-id',
78
- * dataset: 'production',
81
+ * export default function MyApp() {
82
+ * return (
83
+ * <SanityApp
84
+ * resources={{
85
+ * default: { projectId: 'my-project-id', dataset: 'production' },
86
+ * }}
87
+ * fallback={<div>Loading…</div>}
88
+ * >
89
+ * <MyAppRoot />
90
+ * </SanityApp>
91
+ * )
79
92
  * }
93
+ * ```
80
94
  *
81
- * // Or multiple project configurations
82
- * const multipleConfigs: SanityConfig[] = [
83
- * // Configuration for your main project. This will be used as the default project for hooks.
84
- * {
85
- * projectId: 'marketing-website-project',
86
- * dataset: 'production',
87
- * },
88
- * // Configuration for a separate blog project
89
- * {
90
- * projectId: 'blog-project',
91
- * dataset: 'production',
92
- * },
93
- * // Configuration for a separate ecommerce project
94
- * {
95
- * projectId: 'ecommerce-project',
96
- * dataset: 'production',
97
- * }
98
- * ]
95
+ * @example Multiple resources
96
+ * ```tsx
97
+ * import { SanityApp } from '@sanity/sdk-react'
99
98
  *
100
99
  * export default function MyApp() {
101
100
  * return (
102
- * <SanityApp config={mySanityConfig} fallback={<div>Loading…</div>}>
101
+ * <SanityApp
102
+ * resources={{
103
+ * default: { projectId: 'abc123', dataset: 'production' },
104
+ * 'blog-project': { projectId: 'def456', dataset: 'production' },
105
+ * }}
106
+ * fallback={<div>Loading…</div>}
107
+ * >
103
108
  * <MyAppRoot />
104
109
  * </SanityApp>
105
110
  * )
@@ -110,29 +115,42 @@ export function SanityApp({
110
115
  children,
111
116
  fallback,
112
117
  config: configProp,
118
+ resources: resourcesProp,
113
119
  ...props
114
120
  }: SanityAppProps): ReactElement {
115
121
  const studioWorkspace = useContext(SDKStudioContext)
116
122
 
117
- // Derive config: explicit config takes precedence, then Studio context
118
- const resolvedConfig = useMemo(() => {
119
- if (configProp) return configProp
120
- if (studioWorkspace) return deriveConfigFromWorkspace(studioWorkspace)
121
- return []
122
- }, [configProp, studioWorkspace])
123
+ const derived = useMemo(() => {
124
+ if (studioWorkspace && !configProp && !resourcesProp) {
125
+ return deriveFromWorkspace(studioWorkspace)
126
+ }
127
+ return null
128
+ }, [configProp, resourcesProp, studioWorkspace])
129
+
130
+ const resolvedConfig = useMemo<SanityConfig>(() => {
131
+ if (configProp) {
132
+ return configProp
133
+ }
134
+ if (derived) return derived.config
135
+ return {}
136
+ }, [configProp, derived])
137
+
138
+ const resolvedResources = useMemo<Record<string, DocumentResource>>(() => {
139
+ if (resourcesProp) return resourcesProp
140
+ if (derived) return derived.resources
141
+ return {}
142
+ }, [resourcesProp, derived])
123
143
 
124
144
  useEffect(() => {
125
145
  let timeout: NodeJS.Timeout | undefined
126
- const primaryConfig = Array.isArray(resolvedConfig) ? resolvedConfig[0] : resolvedConfig
127
146
  const shouldRedirectWithoutConfig =
128
- configProp === undefined && !studioWorkspace && !primaryConfig
147
+ configProp === undefined && !studioWorkspace && !resolvedConfig
129
148
 
130
149
  if (
131
150
  !isInIframe() &&
132
151
  !isLocalUrl(window) &&
133
- (shouldRedirectWithoutConfig || (!!primaryConfig && !isStudioConfig(primaryConfig)))
152
+ (shouldRedirectWithoutConfig || (!!resolvedConfig && !isStudioConfig(resolvedConfig)))
134
153
  ) {
135
- // If the app is not running in an iframe and is not a local url, redirect to core.
136
154
  timeout = setTimeout(() => {
137
155
  // eslint-disable-next-line no-console
138
156
  console.warn('Redirecting to core', REDIRECT_URL)
@@ -143,7 +161,12 @@ export function SanityApp({
143
161
  }, [configProp, resolvedConfig, studioWorkspace])
144
162
 
145
163
  return (
146
- <SDKProvider {...props} fallback={fallback} config={resolvedConfig}>
164
+ <SDKProvider
165
+ {...props}
166
+ fallback={fallback}
167
+ config={resolvedConfig}
168
+ resources={resolvedResources}
169
+ >
147
170
  {children}
148
171
  </SDKProvider>
149
172
  )
@@ -1,4 +1,4 @@
1
- import {AuthStateType, type SanityConfig} from '@sanity/sdk'
1
+ import {AuthStateType} from '@sanity/sdk'
2
2
  import {render, screen, waitFor} from '@testing-library/react'
3
3
  import React from 'react'
4
4
  import {type FallbackProps} from 'react-error-boundary'
@@ -22,6 +22,9 @@ vi.mock('../../hooks/auth/useHandleAuthCallback', () => ({
22
22
  vi.mock('../../hooks/auth/useLogOut', () => ({
23
23
  useLogOut: vi.fn(() => async () => {}),
24
24
  }))
25
+ vi.mock('../../hooks/comlink/useWindowConnection', () => ({
26
+ useWindowConnection: vi.fn(() => ({fetch: vi.fn()})),
27
+ }))
25
28
 
26
29
  // Mock AuthError throwing scenario
27
30
  vi.mock('./AuthError', async (importOriginal) => {
@@ -107,24 +110,6 @@ describe('AuthBoundary', () => {
107
110
  const mockUseVerifyOrgProjects = vi.mocked(useVerifyOrgProjects)
108
111
  const testProjectIds = ['proj-test'] // Example project ID for tests
109
112
 
110
- // Mock Sanity instance
111
- const mockSanityInstance = {
112
- instanceId: 'test-instance-id',
113
- config: {
114
- projectId: 'test-project',
115
- dataset: 'test-dataset',
116
- },
117
- isDisposed: () => false,
118
- dispose: () => {},
119
- onDispose: () => () => {},
120
- getParent: () => undefined,
121
- createChild: (config: SanityConfig) => ({
122
- ...mockSanityInstance,
123
- config: {...mockSanityInstance.config, ...config},
124
- }),
125
- match: () => undefined,
126
- }
127
-
128
113
  beforeEach(() => {
129
114
  vi.clearAllMocks()
130
115
  consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
@@ -163,7 +148,7 @@ describe('AuthBoundary', () => {
163
148
  isExchangingToken: false,
164
149
  })
165
150
  const {container} = render(
166
- <ResourceProvider projectId="p" dataset="d" fallback={null}>
151
+ <ResourceProvider resource={{projectId: 'p', dataset: 'd'}} fallback={null}>
167
152
  <AuthBoundary projectIds={testProjectIds}>Protected Content</AuthBoundary>
168
153
  </ResourceProvider>,
169
154
  )
@@ -180,7 +165,7 @@ describe('AuthBoundary', () => {
180
165
  token: 'exampleToken',
181
166
  })
182
167
  render(
183
- <ResourceProvider projectId="p" dataset="d" fallback={null}>
168
+ <ResourceProvider resource={{projectId: 'p', dataset: 'd'}} fallback={null}>
184
169
  <AuthBoundary projectIds={testProjectIds}>Protected Content</AuthBoundary>
185
170
  </ResourceProvider>,
186
171
  )
@@ -194,7 +179,7 @@ describe('AuthBoundary', () => {
194
179
  error: new Error('test error'),
195
180
  })
196
181
  render(
197
- <ResourceProvider projectId="p" dataset="d" fallback={null}>
182
+ <ResourceProvider resource={{projectId: 'p', dataset: 'd'}} fallback={null}>
198
183
  <AuthBoundary projectIds={testProjectIds}>Protected Content</AuthBoundary>
199
184
  </ResourceProvider>,
200
185
  )
@@ -211,7 +196,7 @@ describe('AuthBoundary', () => {
211
196
 
212
197
  it('renders children when logged in and org verification passes', () => {
213
198
  render(
214
- <ResourceProvider projectId="p" dataset="d" fallback={null}>
199
+ <ResourceProvider resource={{projectId: 'p', dataset: 'd'}} fallback={null}>
215
200
  <AuthBoundary projectIds={testProjectIds}>Protected Content</AuthBoundary>
216
201
  </ResourceProvider>,
217
202
  )
@@ -233,7 +218,7 @@ describe('AuthBoundary', () => {
233
218
 
234
219
  // Need to catch the error thrown during render. ErrorBoundary mock handles this.
235
220
  render(
236
- <ResourceProvider projectId="p" dataset="d" fallback={null}>
221
+ <ResourceProvider resource={{projectId: 'p', dataset: 'd'}} fallback={null}>
237
222
  <AuthBoundary verifyOrganization={true} projectIds={testProjectIds}>
238
223
  <div>Protected Content</div>
239
224
  </AuthBoundary>
@@ -265,7 +250,7 @@ describe('AuthBoundary', () => {
265
250
  })
266
251
 
267
252
  render(
268
- <ResourceProvider projectId="p" dataset="d" fallback={null}>
253
+ <ResourceProvider resource={{projectId: 'p', dataset: 'd'}} fallback={null}>
269
254
  <AuthBoundary verifyOrganization={false} projectIds={testProjectIds}>
270
255
  <div>Protected Content</div>
271
256
  </AuthBoundary>
@@ -290,7 +275,7 @@ describe('AuthBoundary', () => {
290
275
  mockUseVerifyOrgProjects.mockImplementation(() => null)
291
276
 
292
277
  render(
293
- <ResourceProvider projectId="p" dataset="d" fallback={null}>
278
+ <ResourceProvider resource={{projectId: 'p', dataset: 'd'}} fallback={null}>
294
279
  <AuthBoundary projectIds={testProjectIds}>
295
280
  <div>Protected Content</div>
296
281
  </AuthBoundary>
@@ -9,6 +9,10 @@ vi.mock('../../hooks/auth/useLogOut', () => ({
9
9
  useLogOut: vi.fn(() => async () => {}),
10
10
  }))
11
11
 
12
+ vi.mock('../../hooks/comlink/useWindowConnection', () => ({
13
+ useWindowConnection: vi.fn(() => ({fetch: vi.fn()})),
14
+ }))
15
+
12
16
  describe('LoginError', () => {
13
17
  it('shows authentication error and retry button', async () => {
14
18
  const mockReset = vi.fn()
@@ -21,6 +25,7 @@ describe('LoginError', () => {
21
25
  )
22
26
 
23
27
  expect(screen.getByText('Authentication Error')).toBeInTheDocument()
28
+
24
29
  const retryButton = screen.getByRole('button', {name: 'Retry'})
25
30
  fireEvent.click(retryButton)
26
31
 
@@ -1,15 +1,19 @@
1
1
  import {ClientError} from '@sanity/client'
2
+ import {SDK_CHANNEL_NAME, SDK_NODE_NAME} from '@sanity/message-protocol'
2
3
  import {
3
4
  AuthStateType,
4
5
  getClientErrorApiBody,
5
6
  getClientErrorApiDescription,
7
+ isDatasetResource,
6
8
  isProjectUserNotFoundClientError,
7
9
  } from '@sanity/sdk'
8
- import {useCallback, useEffect, useState} from 'react'
10
+ import {useCallback, useContext, useEffect, useState} from 'react'
9
11
  import {type FallbackProps} from 'react-error-boundary'
10
12
 
13
+ import {ResourceContext} from '../../context/DefaultResourceContext'
11
14
  import {useAuthState} from '../../hooks/auth/useAuthState'
12
15
  import {useLogOut} from '../../hooks/auth/useLogOut'
16
+ import {useWindowConnection} from '../../hooks/comlink/useWindowConnection'
13
17
  import {Error} from '../errors/Error'
14
18
  import {AuthError} from './AuthError'
15
19
  import {ConfigurationError} from './ConfigurationError'
@@ -36,12 +40,22 @@ export function LoginError({error, resetErrorBoundary}: LoginErrorProps): React.
36
40
 
37
41
  const logout = useLogOut()
38
42
  const authState = useAuthState()
43
+ const resource = useContext(ResourceContext)
44
+ const projectId = resource && isDatasetResource(resource) ? resource.projectId : undefined
39
45
 
40
46
  const [authErrorMessage, setAuthErrorMessage] = useState(
41
47
  'Please try again or contact support if the problem persists.',
42
48
  )
43
49
  const [showRetryCta, setShowRetryCta] = useState(true)
44
50
 
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
+ })
58
+
45
59
  const handleRetry = useCallback(async () => {
46
60
  await logout()
47
61
  resetErrorBoundary()
@@ -55,6 +69,13 @@ export function LoginError({error, resetErrorBoundary}: LoginErrorProps): React.
55
69
  const description = getClientErrorApiDescription(error)
56
70
  if (description) setAuthErrorMessage(description)
57
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
+ })
58
79
  } else {
59
80
  setShowRetryCta(true)
60
81
  handleRetry()
@@ -73,7 +94,7 @@ export function LoginError({error, resetErrorBoundary}: LoginErrorProps): React.
73
94
  setAuthErrorMessage(error.message)
74
95
  setShowRetryCta(true)
75
96
  }
76
- }, [authState, handleRetry, error])
97
+ }, [authState, handleRetry, error, fetch, projectId])
77
98
 
78
99
  return (
79
100
  <Error
@@ -0,0 +1,53 @@
1
+ import {type DocumentResource, type PerspectiveHandle} from '@sanity/sdk'
2
+
3
+ // React-layer types — shadow core equivalents when imported from @sanity/sdk-react.
4
+ // resource is optional here and resolved from context by normalization,
5
+ // whereas core's DocumentHandle/ResourceHandle require resource explicitly.
6
+
7
+ /**
8
+ * SDK React ResourceHandle with optional explicit resource field.
9
+ * Resource is resolved from context when not provided.
10
+ * When a `resourceName` is provided, the resource will be resolved from the context using the `ResourcesContext`,
11
+ * if there is a matching resource by that name.
12
+ * @public
13
+ */
14
+ export interface ResourceHandle<
15
+ TProjectId extends string = string,
16
+ TDataset extends string = string,
17
+ > {
18
+ resource?: DocumentResource<TProjectId, TDataset>
19
+ resourceName?: string
20
+ perspective?: PerspectiveHandle['perspective']
21
+ }
22
+
23
+ /**
24
+ * SDK React DocumentTypeHandle with optional explicit resource field.
25
+ * Resource is resolved from context when not provided.
26
+ * When a `resourceName` is provided, the resource will be resolved from the context using the `ResourcesContext`,
27
+ * if there is a matching resource by that name.
28
+ * @public
29
+ */
30
+ export interface DocumentTypeHandle<
31
+ TDocumentType extends string = string,
32
+ TDataset extends string = string,
33
+ TProjectId extends string = string,
34
+ > extends ResourceHandle<TProjectId, TDataset> {
35
+ documentType: TDocumentType
36
+ documentId?: string
37
+ liveEdit?: boolean
38
+ }
39
+
40
+ /**
41
+ * SDK React DocumentHandle with optional explicit resource field.
42
+ * Resource is resolved from context when not provided.
43
+ * When a `resourceName` is provided, the resource will be resolved from the context using the `ResourcesContext`,
44
+ * if there is a matching resource by that name.
45
+ * @public
46
+ */
47
+ export interface DocumentHandle<
48
+ TDocumentType extends string = string,
49
+ TDataset extends string = string,
50
+ TProjectId extends string = string,
51
+ > extends DocumentTypeHandle<TDocumentType, TDataset, TProjectId> {
52
+ documentId: string
53
+ }
@@ -56,7 +56,10 @@ describe('ComlinkTokenRefresh', () => {
56
56
  it('should not request new token on 401 if not in dashboard', async () => {
57
57
  mockUseAuthState.mockReturnValue({type: AuthStateType.LOGGED_IN})
58
58
  const {rerender} = render(
59
- <ResourceProvider projectId="test-project" dataset="test-dataset" fallback={null}>
59
+ <ResourceProvider
60
+ resource={{projectId: 'test-project', dataset: 'test-dataset'}}
61
+ fallback={null}
62
+ >
60
63
  <ComlinkTokenRefreshProvider>
61
64
  <div>Test</div>
62
65
  </ComlinkTokenRefreshProvider>
@@ -69,7 +72,10 @@ describe('ComlinkTokenRefresh', () => {
69
72
  })
70
73
  act(() => {
71
74
  rerender(
72
- <ResourceProvider projectId="test-project" dataset="test-dataset" fallback={null}>
75
+ <ResourceProvider
76
+ resource={{projectId: 'test-project', dataset: 'test-dataset'}}
77
+ fallback={null}
78
+ >
73
79
  <ComlinkTokenRefreshProvider>
74
80
  <div>Test</div>
75
81
  </ComlinkTokenRefreshProvider>
@@ -92,7 +98,10 @@ describe('ComlinkTokenRefresh', () => {
92
98
  it('should initialize useWindowConnection with correct parameters when not in studio mode', () => {
93
99
  // Simulate studio mode disabled by default
94
100
  render(
95
- <ResourceProvider projectId="test-project" dataset="test-dataset" fallback={null}>
101
+ <ResourceProvider
102
+ resource={{projectId: 'test-project', dataset: 'test-dataset'}}
103
+ fallback={null}
104
+ >
96
105
  <ComlinkTokenRefreshProvider>
97
106
  <div>Test</div>
98
107
  </ComlinkTokenRefreshProvider>
@@ -124,7 +133,10 @@ describe('ComlinkTokenRefresh', () => {
124
133
  document.body.appendChild(errorContainer)
125
134
 
126
135
  render(
127
- <ResourceProvider projectId="test-project" dataset="test-dataset" fallback={null}>
136
+ <ResourceProvider
137
+ resource={{projectId: 'test-project', dataset: 'test-dataset'}}
138
+ fallback={null}
139
+ >
128
140
  <ComlinkTokenRefreshProvider>
129
141
  <div>Test</div>
130
142
  </ComlinkTokenRefreshProvider>
@@ -138,11 +150,10 @@ describe('ComlinkTokenRefresh', () => {
138
150
  expect(mockSetAuthToken).toHaveBeenCalledWith(expect.any(Object), 'new-token')
139
151
  expect(mockFetch).toHaveBeenCalledTimes(1)
140
152
  expect(mockFetch).toHaveBeenCalledWith('dashboard/v1/auth/tokens/create')
141
- // Assert setAuthToken was called with instance matching provider config
153
+ // Assert setAuthToken was called with a SanityInstance
142
154
  const instanceArg = mockSetAuthToken.mock.calls[0][0]
143
- expect(instanceArg.config).toEqual(
144
- expect.objectContaining({projectId: 'test-project', dataset: 'test-dataset'}),
145
- )
155
+ expect(instanceArg).toHaveProperty('instanceId')
156
+ expect(instanceArg).toHaveProperty('config')
146
157
  // Unauthorized error container should be removed
147
158
  expect(document.getElementById('__sanityError')).toBeNull()
148
159
  })
@@ -155,7 +166,10 @@ describe('ComlinkTokenRefresh', () => {
155
166
  mockFetch.mockResolvedValueOnce({token: null})
156
167
 
157
168
  render(
158
- <ResourceProvider projectId="test-project" dataset="test-dataset" fallback={null}>
169
+ <ResourceProvider
170
+ resource={{projectId: 'test-project', dataset: 'test-dataset'}}
171
+ fallback={null}
172
+ >
159
173
  <ComlinkTokenRefreshProvider>
160
174
  <div>Test</div>
161
175
  </ComlinkTokenRefreshProvider>
@@ -177,7 +191,10 @@ describe('ComlinkTokenRefresh', () => {
177
191
  mockFetch.mockRejectedValueOnce(new Error('Fetch failed'))
178
192
 
179
193
  render(
180
- <ResourceProvider projectId="test-project" dataset="test-dataset" fallback={null}>
194
+ <ResourceProvider
195
+ resource={{projectId: 'test-project', dataset: 'test-dataset'}}
196
+ fallback={null}
197
+ >
181
198
  <ComlinkTokenRefreshProvider>
182
199
  <div>Test</div>
183
200
  </ComlinkTokenRefreshProvider>
@@ -0,0 +1,10 @@
1
+ import {type DocumentResource} from '@sanity/sdk'
2
+ import {createContext} from 'react'
3
+
4
+ /**
5
+ * Provides the active resource for a subtree.
6
+ * Set by `ResourceProvider` so hooks resolve the correct project/dataset
7
+ * without requiring an explicit `resource` option.
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 a perspective override for nested subtrees.
6
+ * Set by `ResourceProvider` so hooks resolve the correct perspective
7
+ * without requiring an explicit `perspective` option.
8
+ * @internal
9
+ */
10
+ export const PerspectiveContext = createContext<PerspectiveHandle['perspective'] | undefined>(
11
+ undefined,
12
+ )