@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/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +32 -30
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/components/auth/LoginError.test.tsx +91 -0
- package/src/components/auth/LoginError.tsx +5 -2
- package/src/hooks/document/useDocument.test-d.ts +65 -0
- package/src/hooks/document/useDocument.ts +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sanity/sdk-react",
|
|
3
|
-
"version": "3.
|
|
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.
|
|
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.
|
|
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
|
|
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 &&
|
|
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:
|