@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.
Files changed (32) hide show
  1. package/README.md +40 -30
  2. package/dist/index.d.ts +512 -335
  3. package/dist/index.js +540 -332
  4. package/dist/index.js.map +1 -1
  5. package/package.json +37 -39
  6. package/src/_exports/sdk-react.ts +1 -0
  7. package/src/components/SDKProvider.test.tsx +135 -22
  8. package/src/components/SDKProvider.tsx +54 -17
  9. package/src/components/SanityApp.tsx +29 -0
  10. package/src/components/auth/AuthBoundary.recovery.test.tsx +86 -0
  11. package/src/components/auth/AuthBoundary.tsx +11 -1
  12. package/src/context/OrganizationResourcesProvider.test.tsx +189 -0
  13. package/src/context/OrganizationResourcesProvider.tsx +111 -0
  14. package/src/context/ProjectContext.ts +15 -0
  15. package/src/context/ResourceProvider.test.tsx +57 -1
  16. package/src/context/ResourceProvider.tsx +30 -9
  17. package/src/hooks/datasets/useDatasets.test.tsx +116 -0
  18. package/src/hooks/datasets/useDatasets.ts +20 -8
  19. package/src/hooks/document/useApplyDocumentActions.ts +1 -1
  20. package/src/hooks/document/useCreateDocument.test.tsx +83 -0
  21. package/src/hooks/document/useCreateDocument.ts +117 -0
  22. package/src/hooks/document/useDocument.ts +2 -2
  23. package/src/hooks/helpers/useResolvedProjectId.test.tsx +59 -0
  24. package/src/hooks/helpers/useResolvedProjectId.ts +35 -0
  25. package/src/hooks/projects/useProject.test.tsx +114 -0
  26. package/src/hooks/projects/useProject.ts +17 -7
  27. package/src/hooks/users/useUsers.test.tsx +101 -2
  28. package/src/hooks/users/useUsers.ts +35 -3
  29. package/src/utils/resolveOrgResources.test.ts +111 -0
  30. package/src/utils/resolveOrgResources.ts +69 -0
  31. package/src/hooks/datasets/useDatasets.test.ts +0 -80
  32. 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.14.1",
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
- "content"
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
- "license": "MIT",
25
- "author": "Sanity <developers@sanity.io>",
26
- "sideEffects": false,
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
- "main": "./dist/index.js",
37
- "module": "./dist/index.js",
38
- "types": "./dist/index.d.ts",
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.22.0",
47
+ "@sanity/client": "^7.23.1",
47
48
  "@sanity/message-protocol": "^0.23.0",
48
- "@sanity/types": "^6.0.0",
49
+ "@sanity/types": "^6.2.0",
49
50
  "groq": "3.88.1-typegen-experimental.0",
50
- "react-compiler-runtime": "19.1.0-rc.2",
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.14.1"
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.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": "^5.2.0",
66
- "@vitest/coverage-v8": "^4.1.8",
67
- "babel-plugin-react-compiler": "19.1.0-rc.1",
68
- "eslint": "^9.39.4",
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
- "prettier": "^3.8.4",
71
+ "oxfmt": "^0.58.0",
72
72
  "react": "^19.2.7",
73
73
  "react-dom": "^19.2.7",
74
- "rollup-plugin-visualizer": "^6.0.11",
75
- "typescript": "^5.9.3",
76
- "vite": "^7.3.5",
77
- "vitest": "^4.1.8",
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
- "publishConfig": {
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": "prettier --write --cache --ignore-unknown .",
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
- // Mock ResourceProvider to test nesting behavior
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
- ...props
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
- projectId?: string
15
- dataset?: string
16
- }) => {
17
- return (
18
- <div
19
- data-testid="resource-provider"
20
- data-config={JSON.stringify({
21
- projectId: props.projectId,
22
- dataset: props.dataset,
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
- return <div data-testid="auth-boundary">{children}</div>
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 {type DocumentResource, isImportError, type SanityConfig} from '@sanity/sdk'
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
- const allConfigs = Array.isArray(config) ? config : [config]
46
- const resolvedConfig = allConfigs[0]
47
- const projectIds = allConfigs.map((c) => c.projectId).filter((id): id is string => !!id)
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]: {projectId: defaultProjectId, dataset: defaultDataset},
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
- <ResourceProvider {...resolvedConfig} fallback={fallback}>
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
- <ResourcesContext.Provider value={resourcesValue}>{children}</ResourcesContext.Provider>
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>