@sanity/sdk-react 2.14.1 → 2.16.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.
- package/README.md +40 -30
- package/dist/index.d.ts +512 -335
- package/dist/index.js +540 -332
- package/dist/index.js.map +1 -1
- package/package.json +37 -39
- package/src/_exports/sdk-react.ts +1 -0
- package/src/components/SDKProvider.test.tsx +135 -22
- package/src/components/SDKProvider.tsx +54 -17
- package/src/components/SanityApp.tsx +29 -0
- package/src/components/auth/AuthBoundary.recovery.test.tsx +86 -0
- package/src/components/auth/AuthBoundary.tsx +11 -1
- package/src/context/OrganizationResourcesProvider.test.tsx +189 -0
- package/src/context/OrganizationResourcesProvider.tsx +111 -0
- package/src/context/ProjectContext.ts +15 -0
- package/src/context/ResourceProvider.test.tsx +57 -1
- package/src/context/ResourceProvider.tsx +30 -9
- package/src/hooks/datasets/useDatasets.test.tsx +116 -0
- package/src/hooks/datasets/useDatasets.ts +20 -8
- package/src/hooks/document/useApplyDocumentActions.ts +1 -1
- package/src/hooks/document/useCreateDocument.test.tsx +83 -0
- package/src/hooks/document/useCreateDocument.ts +117 -0
- package/src/hooks/document/useDocument.ts +2 -2
- package/src/hooks/helpers/useResolvedProjectId.test.tsx +59 -0
- package/src/hooks/helpers/useResolvedProjectId.ts +35 -0
- package/src/hooks/projects/useProject.test.tsx +114 -0
- package/src/hooks/projects/useProject.ts +17 -7
- package/src/hooks/users/useUsers.test.tsx +101 -2
- package/src/hooks/users/useUsers.ts +35 -3
- package/src/utils/resolveOrgResources.test.ts +111 -0
- package/src/utils/resolveOrgResources.ts +69 -0
- package/src/hooks/datasets/useDatasets.test.ts +0 -80
- package/src/hooks/projects/useProject.test.ts +0 -80
package/package.json
CHANGED
|
@@ -1,30 +1,37 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sanity/sdk-react",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.16.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Sanity SDK React toolkit for Content OS",
|
|
6
6
|
"keywords": [
|
|
7
|
-
"sanity",
|
|
8
|
-
"sdk",
|
|
9
|
-
"content operating system",
|
|
10
7
|
"cms",
|
|
8
|
+
"content",
|
|
9
|
+
"content operating system",
|
|
11
10
|
"headless",
|
|
12
11
|
"realtime",
|
|
13
|
-
"
|
|
12
|
+
"sanity",
|
|
13
|
+
"sdk"
|
|
14
14
|
],
|
|
15
15
|
"homepage": "https://github.com/sanity-io/sdk/tree/main/packages/react/README.md",
|
|
16
16
|
"bugs": {
|
|
17
17
|
"url": "https://github.com/sanity-io/sdk/issues"
|
|
18
18
|
},
|
|
19
|
+
"license": "MIT",
|
|
20
|
+
"author": "Sanity <developers@sanity.io>",
|
|
19
21
|
"repository": {
|
|
20
22
|
"type": "git",
|
|
21
23
|
"url": "git+https://github.com/sanity-io/sdk.git",
|
|
22
24
|
"directory": "packages/react"
|
|
23
25
|
},
|
|
24
|
-
"
|
|
25
|
-
|
|
26
|
-
|
|
26
|
+
"files": [
|
|
27
|
+
"dist",
|
|
28
|
+
"src"
|
|
29
|
+
],
|
|
27
30
|
"type": "module",
|
|
31
|
+
"sideEffects": false,
|
|
32
|
+
"main": "./dist/index.js",
|
|
33
|
+
"module": "./dist/index.js",
|
|
34
|
+
"types": "./dist/index.d.ts",
|
|
28
35
|
"exports": {
|
|
29
36
|
".": {
|
|
30
37
|
"source": "./src/_exports/index.ts",
|
|
@@ -33,68 +40,59 @@
|
|
|
33
40
|
},
|
|
34
41
|
"./package.json": "./package.json"
|
|
35
42
|
},
|
|
36
|
-
"
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
"files": [
|
|
40
|
-
"dist",
|
|
41
|
-
"src"
|
|
42
|
-
],
|
|
43
|
-
"browserslist": "extends @sanity/browserslist-config",
|
|
44
|
-
"prettier": "@sanity/prettier-config",
|
|
43
|
+
"publishConfig": {
|
|
44
|
+
"access": "public"
|
|
45
|
+
},
|
|
45
46
|
"dependencies": {
|
|
46
|
-
"@sanity/client": "^7.
|
|
47
|
+
"@sanity/client": "^7.23.1",
|
|
47
48
|
"@sanity/message-protocol": "^0.23.0",
|
|
48
|
-
"@sanity/types": "^6.
|
|
49
|
+
"@sanity/types": "^6.2.0",
|
|
49
50
|
"groq": "3.88.1-typegen-experimental.0",
|
|
50
|
-
"react-compiler-runtime": "
|
|
51
|
+
"react-compiler-runtime": "^1.0.0",
|
|
51
52
|
"react-error-boundary": "^6.1.2",
|
|
52
53
|
"rxjs": "^7.8.2",
|
|
53
|
-
"@sanity/sdk": "2.
|
|
54
|
+
"@sanity/sdk": "2.16.0"
|
|
54
55
|
},
|
|
55
56
|
"devDependencies": {
|
|
56
57
|
"@sanity/browserslist-config": "^1.0.5",
|
|
57
58
|
"@sanity/comlink": "^4.0.1",
|
|
58
|
-
"@sanity/pkg-utils": "^10.5.
|
|
59
|
-
"@sanity/prettier-config": "^3.0.0",
|
|
59
|
+
"@sanity/pkg-utils": "^10.5.8",
|
|
60
60
|
"@testing-library/jest-dom": "^6.9.1",
|
|
61
61
|
"@testing-library/react": "^16.3.2",
|
|
62
62
|
"@types/node": "^24.12.4",
|
|
63
63
|
"@types/react": "^19.2.17",
|
|
64
64
|
"@types/react-dom": "^19.2.3",
|
|
65
|
-
"@vitejs/plugin-react": "^
|
|
66
|
-
"@vitest/coverage-v8": "^4.1.
|
|
67
|
-
"babel-plugin-react-compiler": "
|
|
68
|
-
"eslint": "^
|
|
65
|
+
"@vitejs/plugin-react": "^6.0.3",
|
|
66
|
+
"@vitest/coverage-v8": "^4.1.10",
|
|
67
|
+
"babel-plugin-react-compiler": "^1.0.0",
|
|
68
|
+
"eslint": "^10.6.0",
|
|
69
69
|
"groq-js": "^1.30.2",
|
|
70
70
|
"jsdom": "^29.1.1",
|
|
71
|
-
"
|
|
71
|
+
"oxfmt": "^0.58.0",
|
|
72
72
|
"react": "^19.2.7",
|
|
73
73
|
"react-dom": "^19.2.7",
|
|
74
|
-
"rollup-plugin-visualizer": "^
|
|
75
|
-
"typescript": "^
|
|
76
|
-
"vite": "^
|
|
77
|
-
"vitest": "^4.1.
|
|
74
|
+
"rollup-plugin-visualizer": "^7.0.1",
|
|
75
|
+
"typescript": "^6.0.3",
|
|
76
|
+
"vite": "^8.1.4",
|
|
77
|
+
"vitest": "^4.1.10",
|
|
78
78
|
"@repo/config-test": "0.0.1",
|
|
79
|
-
"@repo/config-eslint": "0.0.0",
|
|
80
|
-
"@repo/package.bundle": "3.82.0",
|
|
81
79
|
"@repo/tsconfig": "0.0.1",
|
|
82
|
-
"@repo/package.config": "0.0.1"
|
|
80
|
+
"@repo/package.config": "0.0.1",
|
|
81
|
+
"@repo/package.bundle": "3.82.0",
|
|
82
|
+
"@repo/config-eslint": "0.0.0"
|
|
83
83
|
},
|
|
84
84
|
"peerDependencies": {
|
|
85
85
|
"react": "^18.0.0 || ^19.0.0",
|
|
86
86
|
"react-dom": "^18.0.0 || ^19.0.0"
|
|
87
87
|
},
|
|
88
|
-
"
|
|
89
|
-
"access": "public"
|
|
90
|
-
},
|
|
88
|
+
"browserslist": "extends @sanity/browserslist-config",
|
|
91
89
|
"scripts": {
|
|
92
90
|
"build": "pkg build --strict --clean --check",
|
|
93
91
|
"build:bundle": "vite build --configLoader runner --config package.bundle.ts",
|
|
94
92
|
"clean": "rimraf dist",
|
|
95
93
|
"dev": "pkg watch",
|
|
96
94
|
"docs": "typedoc --json docs/typedoc.json --tsconfig ./tsconfig.dist.json",
|
|
97
|
-
"format": "
|
|
95
|
+
"format": "oxfmt",
|
|
98
96
|
"lint": "eslint .",
|
|
99
97
|
"test": "vitest run",
|
|
100
98
|
"test:coverage": "vitest run --coverage",
|
|
@@ -59,6 +59,7 @@ export {useStudioWorkspacesByProjectIdDataset} from '../hooks/dashboard/useStudi
|
|
|
59
59
|
export {useWindowTitle} from '../hooks/dashboard/useWindowTitle'
|
|
60
60
|
export {useDatasets} from '../hooks/datasets/useDatasets'
|
|
61
61
|
export {useApplyDocumentActions} from '../hooks/document/useApplyDocumentActions'
|
|
62
|
+
export {type CreateDocumentOverrides, useCreateDocument} from '../hooks/document/useCreateDocument'
|
|
62
63
|
export {useDocument} from '../hooks/document/useDocument'
|
|
63
64
|
export {useDocumentEvent} from '../hooks/document/useDocumentEvent'
|
|
64
65
|
export {useDocumentPermissions} from '../hooks/document/useDocumentPermissions'
|
|
@@ -1,41 +1,67 @@
|
|
|
1
|
+
import {createSanityInstance, type SanityConfig} from '@sanity/sdk'
|
|
1
2
|
import {render} from '@testing-library/react'
|
|
2
3
|
import React from 'react'
|
|
3
|
-
import {describe, expect, it, vi} from 'vitest'
|
|
4
|
+
import {beforeEach, describe, expect, it, vi} from 'vitest'
|
|
4
5
|
|
|
6
|
+
import {useDashboardOrganizationId} from '../hooks/auth/useDashboardOrganizationId'
|
|
7
|
+
import {resolveOrgResources} from '../utils/resolveOrgResources'
|
|
5
8
|
import {SDKProvider} from './SDKProvider'
|
|
6
9
|
|
|
7
|
-
|
|
10
|
+
const instance = createSanityInstance()
|
|
11
|
+
|
|
12
|
+
// Returns the same object on every call so the module-level WeakMap cache works across renders.
|
|
13
|
+
vi.mock('../hooks/context/useSanityInstance', () => {
|
|
14
|
+
return {useSanityInstance: () => instance}
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
vi.mock('../hooks/auth/useDashboardOrganizationId', () => ({
|
|
18
|
+
useDashboardOrganizationId: vi.fn(),
|
|
19
|
+
}))
|
|
20
|
+
|
|
21
|
+
vi.mock('../utils/resolveOrgResources', () => ({
|
|
22
|
+
resolveOrgResources: vi.fn(),
|
|
23
|
+
}))
|
|
24
|
+
|
|
8
25
|
vi.mock('../context/ResourceProvider', () => ({
|
|
9
26
|
ResourceProvider: ({
|
|
10
27
|
children,
|
|
11
|
-
|
|
28
|
+
// exclude fallback: it's a real ResourceProvider prop but not part of config
|
|
29
|
+
fallback: _fallback,
|
|
30
|
+
resource,
|
|
31
|
+
...config
|
|
12
32
|
}: {
|
|
13
33
|
children: React.ReactNode
|
|
14
|
-
|
|
15
|
-
dataset?: string
|
|
16
|
-
}) =>
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
{children}
|
|
26
|
-
</div>
|
|
27
|
-
)
|
|
28
|
-
},
|
|
34
|
+
fallback?: React.ReactNode
|
|
35
|
+
resource?: {projectId?: string; dataset?: string}
|
|
36
|
+
}) => (
|
|
37
|
+
<div
|
|
38
|
+
data-testid="resource-provider"
|
|
39
|
+
data-config={JSON.stringify(config)}
|
|
40
|
+
data-resource={JSON.stringify(resource ?? null)}
|
|
41
|
+
>
|
|
42
|
+
{children}
|
|
43
|
+
</div>
|
|
44
|
+
),
|
|
29
45
|
}))
|
|
30
46
|
|
|
31
|
-
// Mock AuthBoundary
|
|
32
47
|
vi.mock('./auth/AuthBoundary', () => ({
|
|
33
|
-
AuthBoundary: ({children}: {children: React.ReactNode}) =>
|
|
34
|
-
|
|
35
|
-
|
|
48
|
+
AuthBoundary: ({children, projectIds}: {children: React.ReactNode; projectIds?: string[]}) => (
|
|
49
|
+
<div data-testid="auth-boundary" data-project-ids={JSON.stringify(projectIds ?? [])}>
|
|
50
|
+
{children}
|
|
51
|
+
</div>
|
|
52
|
+
),
|
|
36
53
|
}))
|
|
37
54
|
|
|
55
|
+
const mockResolveOrgResources = vi.mocked(resolveOrgResources)
|
|
56
|
+
const mockUseDashboardOrganizationId = vi.mocked(useDashboardOrganizationId)
|
|
57
|
+
|
|
38
58
|
describe('SDKProvider', () => {
|
|
59
|
+
beforeEach(() => {
|
|
60
|
+
vi.clearAllMocks()
|
|
61
|
+
mockResolveOrgResources.mockResolvedValue({})
|
|
62
|
+
mockUseDashboardOrganizationId.mockReturnValue(undefined)
|
|
63
|
+
})
|
|
64
|
+
|
|
39
65
|
it('renders single ResourceProvider with AuthBoundary for a single config', () => {
|
|
40
66
|
const config = {
|
|
41
67
|
projectId: 'test-project',
|
|
@@ -93,4 +119,91 @@ describe('SDKProvider', () => {
|
|
|
93
119
|
dataset: 'production',
|
|
94
120
|
})
|
|
95
121
|
})
|
|
122
|
+
|
|
123
|
+
it('preserves non-resource config fields (e.g. studio auth, perspective) on the root provider', () => {
|
|
124
|
+
const tokenSource = {subscribe: () => ({unsubscribe: () => {}})}
|
|
125
|
+
const config: SanityConfig = {
|
|
126
|
+
projectId: 'test-project',
|
|
127
|
+
dataset: 'production',
|
|
128
|
+
perspective: 'published',
|
|
129
|
+
studio: {authenticated: true, auth: {token: tokenSource}},
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const {getAllByTestId} = render(
|
|
133
|
+
<SDKProvider config={config} fallback={<div>Loading...</div>}>
|
|
134
|
+
<div>Child Content</div>
|
|
135
|
+
</SDKProvider>,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
const providers = getAllByTestId('resource-provider')
|
|
139
|
+
// The full first config is spread onto the root ResourceProvider so the
|
|
140
|
+
// root SanityInstance retains auth/perspective/etc. (The token source is a
|
|
141
|
+
// function and doesn't survive JSON serialization, so assert on the
|
|
142
|
+
// serializable fields and that the studio config object is carried through.)
|
|
143
|
+
const spreadConfig = JSON.parse(providers[0].getAttribute('data-config') || '{}')
|
|
144
|
+
expect(spreadConfig).toMatchObject({
|
|
145
|
+
projectId: 'test-project',
|
|
146
|
+
dataset: 'production',
|
|
147
|
+
perspective: 'published',
|
|
148
|
+
studio: {authenticated: true},
|
|
149
|
+
})
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('synthesizes the default resource from the first config when none is explicit', () => {
|
|
153
|
+
const config = {projectId: 'test-project', dataset: 'production'}
|
|
154
|
+
|
|
155
|
+
const {getAllByTestId} = render(
|
|
156
|
+
<SDKProvider config={config} fallback={<div>Loading...</div>}>
|
|
157
|
+
<div>Child Content</div>
|
|
158
|
+
</SDKProvider>,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
const providers = getAllByTestId('resource-provider')
|
|
162
|
+
expect(JSON.parse(providers[0].getAttribute('data-resource') || 'null')).toEqual({
|
|
163
|
+
projectId: 'test-project',
|
|
164
|
+
dataset: 'production',
|
|
165
|
+
})
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('uses an explicitly provided "default" resource over the config-derived one', () => {
|
|
169
|
+
const config = {projectId: 'config-project', dataset: 'production'}
|
|
170
|
+
const resources = {default: {projectId: 'explicit-project', dataset: 'staging'}}
|
|
171
|
+
|
|
172
|
+
const {getAllByTestId} = render(
|
|
173
|
+
<SDKProvider config={config} resources={resources} fallback={<div>Loading...</div>}>
|
|
174
|
+
<div>Child Content</div>
|
|
175
|
+
</SDKProvider>,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
const providers = getAllByTestId('resource-provider')
|
|
179
|
+
// Config fields still flow through for instance creation…
|
|
180
|
+
expect(JSON.parse(providers[0].getAttribute('data-config') || '{}')).toEqual(config)
|
|
181
|
+
// …but the explicit default resource wins as the resource context value.
|
|
182
|
+
expect(JSON.parse(providers[0].getAttribute('data-resource') || 'null')).toEqual(
|
|
183
|
+
resources.default,
|
|
184
|
+
)
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('collects deduped projectIds from both config and explicit resources', () => {
|
|
188
|
+
const configs = [
|
|
189
|
+
{projectId: 'project-1', dataset: 'production'},
|
|
190
|
+
{projectId: 'project-2', dataset: 'production'},
|
|
191
|
+
]
|
|
192
|
+
const resources = {
|
|
193
|
+
// duplicates project-1 (should be deduped)
|
|
194
|
+
default: {projectId: 'project-1', dataset: 'production'},
|
|
195
|
+
other: {projectId: 'project-3', dataset: 'production'},
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const {getByTestId} = render(
|
|
199
|
+
<SDKProvider config={configs} resources={resources} fallback={<div>Loading...</div>}>
|
|
200
|
+
<div>Child Content</div>
|
|
201
|
+
</SDKProvider>,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
const projectIds = JSON.parse(
|
|
205
|
+
getByTestId('auth-boundary').getAttribute('data-project-ids') || '[]',
|
|
206
|
+
)
|
|
207
|
+
expect([...projectIds].sort()).toEqual(['project-1', 'project-2', 'project-3'])
|
|
208
|
+
})
|
|
96
209
|
})
|
|
@@ -1,10 +1,15 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
type DocumentResource,
|
|
3
|
+
isDatasetResource,
|
|
4
|
+
isImportError,
|
|
5
|
+
type SanityConfig,
|
|
6
|
+
} from '@sanity/sdk'
|
|
2
7
|
import {type ReactElement, type ReactNode, useEffect, useMemo} from 'react'
|
|
3
8
|
import {ErrorBoundary, type FallbackProps} from 'react-error-boundary'
|
|
4
9
|
|
|
5
10
|
import {DEFAULT_RESOURCE_NAME} from '../constants'
|
|
11
|
+
import {OrganizationResourcesProvider} from '../context/OrganizationResourcesProvider'
|
|
6
12
|
import {ResourceProvider} from '../context/ResourceProvider'
|
|
7
|
-
import {ResourcesContext} from '../context/ResourcesContext'
|
|
8
13
|
import {AuthBoundary, type AuthBoundaryProps} from './auth/AuthBoundary'
|
|
9
14
|
import {ChunkLoadError} from './errors/ChunkLoadError'
|
|
10
15
|
import {clearChunkReloadFlag} from './errors/chunkReloadStorage'
|
|
@@ -17,6 +22,8 @@ export interface SDKProviderProps extends AuthBoundaryProps {
|
|
|
17
22
|
config: SanityConfig | SanityConfig[]
|
|
18
23
|
fallback: ReactNode
|
|
19
24
|
resources?: Record<string, DocumentResource>
|
|
25
|
+
/** When set, automatically fetches and registers the organization's media library and canvas as named resources. */
|
|
26
|
+
inferMediaLibraryAndCanvas?: boolean
|
|
20
27
|
}
|
|
21
28
|
|
|
22
29
|
/**
|
|
@@ -40,38 +47,68 @@ export function SDKProvider({
|
|
|
40
47
|
children,
|
|
41
48
|
config,
|
|
42
49
|
fallback,
|
|
50
|
+
inferMediaLibraryAndCanvas,
|
|
43
51
|
...props
|
|
44
52
|
}: SDKProviderProps): ReactElement {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
53
|
+
// For legacy config support, extract the first config object as a default resource,
|
|
54
|
+
// matching the legacy behavior nesting ResourceProviders with the first config closest to users' application code.
|
|
55
|
+
// This should be removed when we remove legacy config support.
|
|
56
|
+
const defaultConfig = Array.isArray(config) ? config[0] : config
|
|
57
|
+
const defaultProjectId = defaultConfig?.projectId
|
|
58
|
+
const defaultDataset = defaultConfig?.dataset
|
|
48
59
|
|
|
49
|
-
// Extract static fields so the memo below doesn't take a reference dependency
|
|
50
|
-
// on `config` — inline config objects change identity on every render.
|
|
51
|
-
const singleConfig = Array.isArray(config) ? null : config
|
|
52
|
-
const defaultProjectId = singleConfig?.projectId
|
|
53
|
-
const defaultDataset = singleConfig?.dataset
|
|
54
|
-
|
|
55
|
-
// For a single config, synthesize a 'default' resource from its projectId/dataset
|
|
56
|
-
// so that hooks can resolve it via resourceName: 'default' or fall back to it
|
|
57
|
-
// automatically when no resource info is provided.
|
|
58
60
|
const resourcesValue = useMemo(() => {
|
|
59
61
|
const explicit = props.resources ?? {}
|
|
60
62
|
if (defaultProjectId && defaultDataset && !Object.hasOwn(explicit, DEFAULT_RESOURCE_NAME)) {
|
|
61
63
|
return {
|
|
62
|
-
[DEFAULT_RESOURCE_NAME]: {
|
|
64
|
+
[DEFAULT_RESOURCE_NAME]: {
|
|
65
|
+
projectId: defaultProjectId,
|
|
66
|
+
dataset: defaultDataset,
|
|
67
|
+
},
|
|
63
68
|
...explicit,
|
|
64
69
|
}
|
|
65
70
|
}
|
|
66
71
|
return explicit
|
|
67
72
|
}, [defaultProjectId, defaultDataset, props.resources])
|
|
68
73
|
|
|
74
|
+
// Collect all projectIds from both resources and config, deduped so
|
|
75
|
+
// AuthBoundary doesn't verify the same project more than once.
|
|
76
|
+
const projectIds = useMemo(
|
|
77
|
+
() =>
|
|
78
|
+
Array.from(
|
|
79
|
+
new Set(
|
|
80
|
+
[
|
|
81
|
+
...Object.values(resourcesValue)
|
|
82
|
+
.filter(isDatasetResource)
|
|
83
|
+
.map((resource) => resource.projectId),
|
|
84
|
+
...(Array.isArray(config) ? config : [config]).map((configObj) => configObj.projectId),
|
|
85
|
+
].filter((id): id is string => !!id),
|
|
86
|
+
),
|
|
87
|
+
),
|
|
88
|
+
[resourcesValue, config],
|
|
89
|
+
)
|
|
90
|
+
|
|
69
91
|
return (
|
|
70
92
|
<ErrorBoundary FallbackComponent={ChunkAwareFallback}>
|
|
71
93
|
<ResetChunkReloadFlagOnMount />
|
|
72
|
-
|
|
94
|
+
{/* Spread the first config so SanityInstance can use the auth,
|
|
95
|
+
perspective, and other config fields; `resource` lets an explicitly
|
|
96
|
+
provided `default` resource override the config-derived resource. */}
|
|
97
|
+
<ResourceProvider
|
|
98
|
+
{...defaultConfig}
|
|
99
|
+
resource={resourcesValue[DEFAULT_RESOURCE_NAME]}
|
|
100
|
+
fallback={fallback}
|
|
101
|
+
>
|
|
73
102
|
<AuthBoundary {...props} projectIds={projectIds}>
|
|
74
|
-
|
|
103
|
+
{/* OrganizationResourcesProvider wraps ResourcesContext.
|
|
104
|
+
It merges explicit resources with lazily inferred org resources (media
|
|
105
|
+
library, canvas) and provides the combined map to the subtree. */}
|
|
106
|
+
<OrganizationResourcesProvider
|
|
107
|
+
resources={resourcesValue}
|
|
108
|
+
inferMediaLibraryAndCanvas={inferMediaLibraryAndCanvas}
|
|
109
|
+
>
|
|
110
|
+
{children}
|
|
111
|
+
</OrganizationResourcesProvider>
|
|
75
112
|
</AuthBoundary>
|
|
76
113
|
</ResourceProvider>
|
|
77
114
|
</ErrorBoundary>
|
|
@@ -23,6 +23,35 @@ export interface SanityAppProps {
|
|
|
23
23
|
children: React.ReactNode
|
|
24
24
|
/* Fallback content to show when child components are suspending. Same as the `fallback` prop for React Suspense. */
|
|
25
25
|
fallback: React.ReactNode
|
|
26
|
+
/**
|
|
27
|
+
* Set this to automatically fetch and register the organization's media library and canvas as named resources.
|
|
28
|
+
* These resources will be available to hooks as `media-library` and `canvas`.
|
|
29
|
+
*
|
|
30
|
+
* The SDK App must be running in the organization's Dashboard to use this feature.
|
|
31
|
+
*
|
|
32
|
+
* @example
|
|
33
|
+
* ```tsx
|
|
34
|
+
*
|
|
35
|
+
* const MyApp = () => {
|
|
36
|
+
* // should "just work" because of inferMediaLibraryAndCanvas.
|
|
37
|
+
* const {data: assets} = useDocuments({
|
|
38
|
+
* documentType: 'sanity.asset',
|
|
39
|
+
* resourceName: 'media-library',
|
|
40
|
+
* })
|
|
41
|
+
* return (
|
|
42
|
+
* <div>
|
|
43
|
+
* {assets.map((asset) => (
|
|
44
|
+
* <div key={asset._id}>{asset.originalFilename}</div>
|
|
45
|
+
* ))}
|
|
46
|
+
* </div>
|
|
47
|
+
* )
|
|
48
|
+
* }
|
|
49
|
+
* <SanityApp inferMediaLibraryAndCanvas>
|
|
50
|
+
* <MyApp />
|
|
51
|
+
* </SanityApp>
|
|
52
|
+
* ```
|
|
53
|
+
*/
|
|
54
|
+
inferMediaLibraryAndCanvas?: boolean
|
|
26
55
|
}
|
|
27
56
|
|
|
28
57
|
const REDIRECT_URL = 'https://sanity.io/welcome'
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import {AuthStateType, getIsInDashboardState} from '@sanity/sdk'
|
|
2
|
+
import {render, screen, waitFor} from '@testing-library/react'
|
|
3
|
+
import {beforeEach, describe, expect, it, type Mock, vi} from 'vitest'
|
|
4
|
+
|
|
5
|
+
import {ResourceProvider} from '../../context/ResourceProvider'
|
|
6
|
+
import {useAuthState} from '../../hooks/auth/useAuthState'
|
|
7
|
+
import {AuthBoundary} from './AuthBoundary'
|
|
8
|
+
|
|
9
|
+
// NOTE: unlike AuthBoundary.test.tsx this file does NOT mock react-error-boundary
|
|
10
|
+
// — we want the REAL ErrorBoundary so we can observe whether it recovers when the
|
|
11
|
+
// auth store transitions back to LOGGED_IN (e.g. after a successful silent token
|
|
12
|
+
// refresh via ComlinkTokenRefresh -> setAuthToken).
|
|
13
|
+
|
|
14
|
+
vi.mock('@sanity/sdk', async () => {
|
|
15
|
+
const actual = await vi.importActual('@sanity/sdk')
|
|
16
|
+
return {
|
|
17
|
+
...actual,
|
|
18
|
+
getIsInDashboardState: vi.fn(() => ({getCurrent: () => true})),
|
|
19
|
+
}
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
vi.mock('../../hooks/auth/useAuthState', () => ({useAuthState: vi.fn()}))
|
|
23
|
+
vi.mock('../../hooks/auth/useLoginUrl', () => ({
|
|
24
|
+
useLoginUrl: vi.fn(() => 'https://example.com/login'),
|
|
25
|
+
}))
|
|
26
|
+
vi.mock('../../hooks/auth/useVerifyOrgProjects', () => ({useVerifyOrgProjects: vi.fn(() => null)}))
|
|
27
|
+
vi.mock('../../hooks/auth/useLogOut', () => ({useLogOut: vi.fn(() => async () => {})}))
|
|
28
|
+
vi.mock('../../hooks/auth/useHandleAuthCallback', () => ({
|
|
29
|
+
useHandleAuthCallback: vi.fn(() => async () => {}),
|
|
30
|
+
}))
|
|
31
|
+
vi.mock('../../hooks/comlink/useWindowConnection', () => ({
|
|
32
|
+
useWindowConnection: vi.fn(() => ({fetch: vi.fn().mockResolvedValue({token: 'fresh-token'})})),
|
|
33
|
+
}))
|
|
34
|
+
// Avoid injecting the SanityOS bridge.js <script> during import.
|
|
35
|
+
vi.mock('../utils', () => ({isInIframe: vi.fn(() => false)}))
|
|
36
|
+
|
|
37
|
+
const mockUseAuthState = useAuthState as Mock
|
|
38
|
+
|
|
39
|
+
describe('AuthBoundary — recovery after silent token refresh (dashboard)', () => {
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
vi.clearAllMocks()
|
|
42
|
+
;(getIsInDashboardState as Mock).mockReturnValue({getCurrent: () => true})
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('renders the app again once the session is re-established (LOGGED_IN)', async () => {
|
|
46
|
+
// 1. Session expires: a request 401s and the auth store goes to ERROR.
|
|
47
|
+
mockUseAuthState.mockReturnValue({
|
|
48
|
+
type: AuthStateType.ERROR,
|
|
49
|
+
error: Object.assign(new Error('Unauthorized'), {statusCode: 401}),
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
const {rerender} = render(
|
|
53
|
+
<ResourceProvider projectId="p" dataset="d" fallback={null}>
|
|
54
|
+
<AuthBoundary projectIds={['p']}>
|
|
55
|
+
<div>Protected Content</div>
|
|
56
|
+
</AuthBoundary>
|
|
57
|
+
</ResourceProvider>,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
// The error boundary catches the AuthError and shows the auth-error screen.
|
|
61
|
+
await waitFor(() => {
|
|
62
|
+
expect(screen.getByText('Authentication Error')).toBeInTheDocument()
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
// 2. ComlinkTokenRefresh fetches a fresh token from the Dashboard, setAuthToken
|
|
66
|
+
// lands it, /users/me re-fetches and the store returns to LOGGED_IN.
|
|
67
|
+
mockUseAuthState.mockReturnValue({
|
|
68
|
+
type: AuthStateType.LOGGED_IN,
|
|
69
|
+
currentUser: null,
|
|
70
|
+
token: 'fresh-token',
|
|
71
|
+
})
|
|
72
|
+
rerender(
|
|
73
|
+
<ResourceProvider projectId="p" dataset="d" fallback={null}>
|
|
74
|
+
<AuthBoundary projectIds={['p']}>
|
|
75
|
+
<div>Protected Content</div>
|
|
76
|
+
</AuthBoundary>
|
|
77
|
+
</ResourceProvider>,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
// EXPECTED (desired) behaviour: the app recovers automatically, no Retry click.
|
|
81
|
+
await waitFor(() => {
|
|
82
|
+
expect(screen.getByText('Protected Content')).toBeInTheDocument()
|
|
83
|
+
})
|
|
84
|
+
expect(screen.queryByText('Authentication Error')).not.toBeInTheDocument()
|
|
85
|
+
})
|
|
86
|
+
})
|
|
@@ -109,6 +109,16 @@ export function AuthBoundary({
|
|
|
109
109
|
LoginErrorComponent = LoginError,
|
|
110
110
|
...props
|
|
111
111
|
}: AuthBoundaryProps): React.ReactNode {
|
|
112
|
+
/**
|
|
113
|
+
* When the session is re-established (e.g. ComlinkTokenRefresh silently mints a
|
|
114
|
+
* fresh token via setAuthToken), the auth store returns to LOGGED_IN but the
|
|
115
|
+
* ErrorBoundary stays latched on its fallback until reset. Keying it on the
|
|
116
|
+
* recovered session lets it clear automatically
|
|
117
|
+
*/
|
|
118
|
+
const authState = useAuthState()
|
|
119
|
+
const sessionResetKey =
|
|
120
|
+
authState.type === AuthStateType.LOGGED_IN ? authState.token : authState.type
|
|
121
|
+
|
|
112
122
|
const FallbackComponent = useMemo(() => {
|
|
113
123
|
return function LoginComponentWithLayoutProps(fallbackProps: FallbackProps) {
|
|
114
124
|
// Chunk-load errors from any lazy-loaded code beneath this boundary
|
|
@@ -131,7 +141,7 @@ export function AuthBoundary({
|
|
|
131
141
|
|
|
132
142
|
return (
|
|
133
143
|
<ComlinkTokenRefreshProvider>
|
|
134
|
-
<ErrorBoundary FallbackComponent={FallbackComponent}>
|
|
144
|
+
<ErrorBoundary FallbackComponent={FallbackComponent} resetKeys={[sessionResetKey]}>
|
|
135
145
|
<AuthSwitch {...props} />
|
|
136
146
|
</ErrorBoundary>
|
|
137
147
|
</ComlinkTokenRefreshProvider>
|