@sanity/sdk-react 3.8.0 → 3.9.0-next.20261007114705

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sanity/sdk-react",
3
- "version": "3.8.0",
3
+ "version": "3.9.0-next.20261007114705",
4
4
  "private": false,
5
5
  "description": "Sanity SDK React toolkit for Content OS",
6
6
  "keywords": [
@@ -45,7 +45,7 @@
45
45
  "@module-federation/runtime": "^2.9.2",
46
46
  "@sanity/client": "^8.9.0",
47
47
  "@sanity/message-protocol": "^0.24.0",
48
- "@sanity/sdk": "3.8.0",
48
+ "@sanity/sdk": "3.9.0-next.20261007114705",
49
49
  "@sanity/types": "^6.17.0",
50
50
  "react-compiler-runtime": "^1.0.0",
51
51
  "react-error-boundary": "^6.1.6",
@@ -72,7 +72,7 @@
72
72
  "groq": "^6.15.0",
73
73
  "groq-js": "^2.0.0",
74
74
  "jsdom": "^30.1.2",
75
- "oxfmt": "^0.71.0",
75
+ "oxfmt": "^0.72.0",
76
76
  "react": "^19.3.0",
77
77
  "react-dom": "^19.3.0",
78
78
  "rolldown": "^1.2.12",
@@ -135,6 +135,70 @@ describe('LoginError', () => {
135
135
  })
136
136
  })
137
137
 
138
+ it('does not request access when neither the error nor the config names a project', async () => {
139
+ mockIsDashboardEnvironment.mockReturnValue(true)
140
+ const error = makeClientError(401, {
141
+ error: {
142
+ type: 'projectUserNotFoundError',
143
+ description: 'User is not a member of this project.',
144
+ },
145
+ })
146
+
147
+ render(
148
+ <ResourceProvider fallback={<div>SUSPENDED</div>}>
149
+ <LoginError error={error} resetErrorBoundary={vi.fn()} />
150
+ </ResourceProvider>,
151
+ )
152
+
153
+ expect(await screen.findByText('User is not a member of this project.')).toBeInTheDocument()
154
+ expect(screen.queryByText('SUSPENDED')).not.toBeInTheDocument()
155
+ expect(mockWindowConnectionFetch).not.toHaveBeenCalled()
156
+ })
157
+
158
+ it('prefers the project named in the error over the configured projectId', async () => {
159
+ mockIsDashboardEnvironment.mockReturnValue(true)
160
+ const error = makeClientError(401, {
161
+ error: {type: 'projectUserNotFoundError', description: 'No access.', projectID: 'exx11uqh'},
162
+ })
163
+
164
+ render(
165
+ <ResourceProvider projectId="abc123" dataset="production" fallback={<div>SUSPENDED</div>}>
166
+ <LoginError error={error} resetErrorBoundary={vi.fn()} />
167
+ </ResourceProvider>,
168
+ )
169
+
170
+ await waitFor(() => {
171
+ expect(mockWindowConnectionFetch).toHaveBeenCalledWith('dashboard/v1/auth/access/request', {
172
+ resourceType: 'project',
173
+ resourceId: 'exx11uqh',
174
+ })
175
+ })
176
+ })
177
+
178
+ it('requests access to the project named in the error without a configured projectId', async () => {
179
+ mockIsDashboardEnvironment.mockReturnValue(true)
180
+ const error = makeClientError(401, {
181
+ error: {
182
+ type: 'projectUserNotFoundError',
183
+ description: 'User is not a member of this project.',
184
+ projectID: 'exx11uqh',
185
+ },
186
+ })
187
+
188
+ render(
189
+ <ResourceProvider fallback={<div>SUSPENDED</div>}>
190
+ <LoginError error={error} resetErrorBoundary={vi.fn()} />
191
+ </ResourceProvider>,
192
+ )
193
+
194
+ await waitFor(() => {
195
+ expect(mockWindowConnectionFetch).toHaveBeenCalledWith('dashboard/v1/auth/access/request', {
196
+ resourceType: 'project',
197
+ resourceId: 'exx11uqh',
198
+ })
199
+ })
200
+ })
201
+
138
202
  // Mirrors the real production chain: AuthBoundary wraps the ClientError in
139
203
  // an AuthError before the error boundary hands it to LoginError. The
140
204
  // `.cause` unwrap is what makes the dashboard access request path reachable
@@ -283,6 +347,33 @@ describe('LoginError', () => {
283
347
  expect(mockLogout).not.toHaveBeenCalled()
284
348
  })
285
349
 
350
+ it('requests access over the bus to the project named in the error', async () => {
351
+ const requests: PayloadOf<'access.request'>[] = []
352
+ host.subscribe('access.request', (message) => {
353
+ requests.push(message.payload)
354
+ message.reply({ok: true})
355
+ })
356
+ const error = new AuthError(
357
+ makeClientError(401, {
358
+ error: {
359
+ type: 'projectUserNotFoundError',
360
+ description: 'No access.',
361
+ projectID: 'exx11uqh',
362
+ },
363
+ }),
364
+ )
365
+
366
+ render(
367
+ <ResourceProvider fallback={<div>SUSPENDED</div>}>
368
+ <LoginError error={error} resetErrorBoundary={vi.fn()} />
369
+ </ResourceProvider>,
370
+ )
371
+
372
+ await waitFor(() => {
373
+ expect(requests).toEqual([{resourceType: 'project', resourceId: 'exx11uqh'}])
374
+ })
375
+ })
376
+
286
377
  const projectUserNotFound = () =>
287
378
  new AuthError(
288
379
  makeClientError(401, {
@@ -3,6 +3,7 @@ import {AuthStateType} from '@sanity/sdk'
3
3
  import {
4
4
  getClientErrorApiBody,
5
5
  getClientErrorApiDescription,
6
+ getClientErrorApiProjectId,
6
7
  isDashboardEnvironment,
7
8
  isProjectUserNotFoundClientError,
8
9
  } from '@sanity/sdk/_internal'
@@ -60,13 +61,15 @@ export function LoginError({error, resetErrorBoundary}: LoginErrorProps): React.
60
61
  const isProjectUserNotFound =
61
62
  !!clientError && clientError.statusCode === 401 && isProjectUserNotFoundClientError(clientError)
62
63
 
64
+ const accessProjectId = clientError && getClientErrorApiProjectId(clientError)
65
+
63
66
  // The dashboard access request flow relies on a comlink connection to the
64
67
  // parent window. In standalone apps that connection never materializes, so
65
68
  // 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
69
+ // Suspense boundary. Resolving to a project ID (or null) here lets the JSX
67
70
  // render the child with a single non-null guard.
68
71
  const dashboardAccessProjectId =
69
- isProjectUserNotFound && projectId && isInDashboard ? projectId : null
72
+ isProjectUserNotFound && isInDashboard ? accessProjectId || projectId || null : null
70
73
 
71
74
  const handleRetry = useCallback(async () => {
72
75
  await logout()
@@ -0,0 +1,65 @@
1
+ import {createDocumentHandle} from '@sanity/sdk'
2
+ import {expectTypeOf, test} from 'vitest'
3
+
4
+ import {useDocument} from './useDocument'
5
+
6
+ // End-to-end check that the resource-keyed lookup reaches the hook. The resolver reads
7
+ // `SanitySchemasByResource`; this asserts `useDocument` passes the handle's project and
8
+ // dataset through to it.
9
+ //
10
+ // Only covers the form where the literals are written at the call site. A handle built from
11
+ // a selected resource, or arriving as a `DocumentHandle<'post'>` prop, still carries `string`
12
+ // and resolves to nothing. That is the propagation work, tracked separately.
13
+
14
+ type HookPost = {
15
+ _id: string
16
+ _type: 'post'
17
+ _createdAt: string
18
+ _updatedAt: string
19
+ _rev: string
20
+ title: string
21
+ }
22
+ interface ProductionPost extends Omit<HookPost, 'title'> {
23
+ title: number
24
+ }
25
+
26
+ // Registered the way generated files do: as globals, which the interfaces `@sanity/client`
27
+ // exports extend. Every file in this package's type-check program sees these registrations, not
28
+ // only this test, so their keys must stay unique across the package's tests.
29
+ declare global {
30
+ interface SanitySchemasByResource {
31
+ 'hook1.test': HookPost
32
+ 'hook2.production': ProductionPost
33
+ }
34
+ }
35
+
36
+ const testHandle = createDocumentHandle({
37
+ projectId: 'hook1',
38
+ dataset: 'test',
39
+ documentId: 'a',
40
+ documentType: 'post',
41
+ })
42
+ const productionHandle = createDocumentHandle({
43
+ projectId: 'hook2',
44
+ dataset: 'production',
45
+ documentId: 'a',
46
+ documentType: 'post',
47
+ })
48
+
49
+ test('useDocument resolves the document type for each handle resource', () => {
50
+ expectTypeOf(useDocument(testHandle).data).toEqualTypeOf<HookPost | null>()
51
+ expectTypeOf(useDocument(productionHandle).data).toEqualTypeOf<ProductionPost | null>()
52
+ })
53
+
54
+ test('useDocument preserves the resource when selecting a field', () => {
55
+ expectTypeOf(useDocument({...testHandle, path: 'title'}).data).toEqualTypeOf<string | undefined>()
56
+ expectTypeOf(useDocument({...productionHandle, path: 'title'}).data).toEqualTypeOf<
57
+ number | undefined
58
+ >()
59
+ })
60
+
61
+ test('explicit resource generics require a matching handle', () => {
62
+ useDocument<'title', 'post', 'test', 'hook1'>({...testHandle, path: 'title'})
63
+ // @ts-expect-error The handle belongs to a different project and dataset.
64
+ useDocument<'title', 'post', 'test', 'hook1'>({...productionHandle, path: 'title'})
65
+ })
@@ -61,7 +61,7 @@ interface UseDocument {
61
61
  TDataset extends string = string,
62
62
  TProjectId extends string = string,
63
63
  >(
64
- options: UseDocumentOptions<TPath, TDocumentType>,
64
+ options: UseDocumentOptions<TPath, TDocumentType, TDataset, TProjectId>,
65
65
  ): {
66
66
  data: JsonMatch<ResolveDocument<TDocumentType, `${TProjectId}.${TDataset}`>, TPath> | undefined
67
67
  }
@@ -139,7 +139,7 @@ interface UseDocument {
139
139
  TDataset extends string = string,
140
140
  TProjectId extends string = string,
141
141
  >(
142
- options: UseDocumentOptions<TPath, TDocumentType>,
142
+ options: UseDocumentOptions<TPath, TDocumentType, TDataset, TProjectId>,
143
143
  ): TPath extends string
144
144
  ? {
145
145
  data: