@sanity/sdk-react 2.9.0 → 2.10.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 (44) hide show
  1. package/dist/index.d.ts +92 -26
  2. package/dist/index.js +304 -193
  3. package/dist/index.js.map +1 -1
  4. package/package.json +9 -11
  5. package/src/_exports/sdk-react.ts +4 -0
  6. package/src/components/SDKProvider.tsx +36 -8
  7. package/src/components/SanityApp.tsx +2 -2
  8. package/src/components/auth/AuthBoundary.tsx +8 -1
  9. package/src/components/auth/DashboardAccessRequest.tsx +37 -0
  10. package/src/components/auth/LoginError.test.tsx +191 -5
  11. package/src/components/auth/LoginError.tsx +100 -56
  12. package/src/components/errors/ChunkLoadError.test.tsx +59 -0
  13. package/src/components/errors/ChunkLoadError.tsx +56 -0
  14. package/src/components/errors/chunkReloadStorage.ts +57 -0
  15. package/src/context/ResourceProvider.tsx +5 -4
  16. package/src/context/ResourcesContext.tsx +7 -0
  17. package/src/context/SanityInstanceProvider.test.tsx +100 -0
  18. package/src/context/SanityInstanceProvider.tsx +71 -0
  19. package/src/hooks/auth/useVerifyOrgProjects.tsx +13 -6
  20. package/src/hooks/dashboard/useDispatchIntent.test.ts +6 -6
  21. package/src/hooks/dashboard/useDispatchIntent.ts +6 -6
  22. package/src/hooks/dashboard/utils/useResourceIdFromDocumentHandle.test.ts +15 -15
  23. package/src/hooks/dashboard/utils/useResourceIdFromDocumentHandle.ts +13 -13
  24. package/src/hooks/document/useApplyDocumentActions.test.ts +10 -10
  25. package/src/hooks/document/useApplyDocumentActions.ts +17 -17
  26. package/src/hooks/document/useDocument.ts +5 -5
  27. package/src/hooks/document/useDocumentEvent.ts +4 -4
  28. package/src/hooks/document/useDocumentPermissions.test.tsx +10 -10
  29. package/src/hooks/document/useDocumentPermissions.ts +8 -8
  30. package/src/hooks/document/useDocumentSyncStatus.ts +2 -2
  31. package/src/hooks/document/useEditDocument.ts +2 -2
  32. package/src/hooks/documents/useDocuments.ts +9 -6
  33. package/src/hooks/helpers/useNormalizedResourceOptions.ts +131 -0
  34. package/src/hooks/helpers/useTrackHookUsage.ts +2 -2
  35. package/src/hooks/paginatedDocuments/usePaginatedDocuments.ts +9 -8
  36. package/src/hooks/presence/usePresence.test.tsx +56 -9
  37. package/src/hooks/presence/usePresence.ts +23 -4
  38. package/src/hooks/preview/useDocumentPreview.tsx +8 -7
  39. package/src/hooks/projection/useDocumentProjection.ts +6 -6
  40. package/src/hooks/query/useQuery.ts +10 -9
  41. package/src/hooks/releases/useActiveReleases.ts +10 -10
  42. package/src/hooks/releases/usePerspective.ts +9 -9
  43. package/src/context/SourcesContext.tsx +0 -7
  44. package/src/hooks/helpers/useNormalizedSourceOptions.ts +0 -107
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sanity/sdk-react",
3
- "version": "2.9.0",
3
+ "version": "2.10.0",
4
4
  "private": false,
5
5
  "description": "Sanity SDK React toolkit for Content OS",
6
6
  "keywords": [
@@ -43,16 +43,14 @@
43
43
  "browserslist": "extends @sanity/browserslist-config",
44
44
  "prettier": "@sanity/prettier-config",
45
45
  "dependencies": {
46
- "@sanity/client": "^7.14.1",
47
- "@sanity/message-protocol": "^0.18.0",
46
+ "@sanity/client": "^7.22.0",
47
+ "@sanity/message-protocol": "^0.23.0",
48
48
  "@sanity/types": "^5.2.0",
49
- "@types/lodash-es": "^4.17.12",
50
49
  "groq": "3.88.1-typegen-experimental.0",
51
- "lodash-es": "^4.17.21",
52
50
  "react-compiler-runtime": "19.1.0-rc.2",
53
51
  "react-error-boundary": "^5.0.0",
54
52
  "rxjs": "^7.8.2",
55
- "@sanity/sdk": "2.9.0"
53
+ "@sanity/sdk": "2.10.0"
56
54
  },
57
55
  "devDependencies": {
58
56
  "@sanity/browserslist-config": "^1.0.5",
@@ -69,7 +67,7 @@
69
67
  "babel-plugin-react-compiler": "19.1.0-rc.1",
70
68
  "eslint": "^9.22.0",
71
69
  "groq-js": "^1.22.0",
72
- "jsdom": "^25.0.1",
70
+ "jsdom": "^29.0.2",
73
71
  "prettier": "^3.7.3",
74
72
  "react": "^19.2.1",
75
73
  "react-dom": "^19.2.1",
@@ -77,11 +75,11 @@
77
75
  "typescript": "^5.8.3",
78
76
  "vite": "^7.0.0",
79
77
  "vitest": "^3.2.4",
80
- "@repo/tsconfig": "0.0.1",
81
- "@repo/package.config": "0.0.1",
78
+ "@repo/config-eslint": "0.0.0",
82
79
  "@repo/config-test": "0.0.1",
83
- "@repo/package.bundle": "3.82.0",
84
- "@repo/config-eslint": "0.0.0"
80
+ "@repo/package.config": "0.0.1",
81
+ "@repo/tsconfig": "0.0.1",
82
+ "@repo/package.bundle": "3.82.0"
85
83
  },
86
84
  "peerDependencies": {
87
85
  "react": "^18.0.0 || ^19.0.0",
@@ -7,6 +7,10 @@ export {SDKProvider, type SDKProviderProps} from '../components/SDKProvider'
7
7
  export {ComlinkTokenRefreshProvider} from '../context/ComlinkTokenRefresh'
8
8
  export {renderSanityApp} from '../context/renderSanityApp'
9
9
  export {ResourceProvider, type ResourceProviderProps} from '../context/ResourceProvider'
10
+ export {
11
+ SanityInstanceProvider,
12
+ type SanityInstanceProviderProps,
13
+ } from '../context/SanityInstanceProvider'
10
14
  export {SDKStudioContext, type StudioWorkspaceHandle} from '../context/SDKStudioContext'
11
15
  export {
12
16
  useAgentGenerate,
@@ -1,9 +1,12 @@
1
- import {type DocumentSource, type SanityConfig} from '@sanity/sdk'
2
- import {type ReactElement, type ReactNode, useMemo} from 'react'
1
+ import {type DocumentResource, isImportError, type SanityConfig} from '@sanity/sdk'
2
+ import {type ReactElement, type ReactNode, useEffect, useMemo} from 'react'
3
+ import {ErrorBoundary, type FallbackProps} from 'react-error-boundary'
3
4
 
4
5
  import {ResourceProvider} from '../context/ResourceProvider'
5
- import {SourcesContext} from '../context/SourcesContext'
6
+ import {ResourcesContext} from '../context/ResourcesContext'
6
7
  import {AuthBoundary, type AuthBoundaryProps} from './auth/AuthBoundary'
8
+ import {ChunkLoadError} from './errors/ChunkLoadError'
9
+ import {clearChunkReloadFlag} from './errors/chunkReloadStorage'
7
10
 
8
11
  /**
9
12
  * @internal
@@ -12,7 +15,19 @@ export interface SDKProviderProps extends AuthBoundaryProps {
12
15
  children: ReactNode
13
16
  config: SanityConfig | SanityConfig[]
14
17
  fallback: ReactNode
15
- sources?: Record<string, DocumentSource>
18
+ resources?: Record<string, DocumentResource>
19
+ }
20
+
21
+ /**
22
+ * Clears the chunk-reload flag once children render successfully past the
23
+ * top-level boundary, so a future incident in the same session can trigger
24
+ * another automatic reload.
25
+ */
26
+ function ResetChunkReloadFlagOnMount(): null {
27
+ useEffect(() => {
28
+ clearChunkReloadFlag()
29
+ }, [])
30
+ return null
16
31
  }
17
32
 
18
33
  /**
@@ -33,15 +48,15 @@ export function SDKProvider({
33
48
  const configs = (Array.isArray(config) ? config : [config]).slice().reverse()
34
49
  const projectIds = configs.map((c) => c.projectId).filter((id): id is string => !!id)
35
50
 
36
- // Memoize sources to prevent creating a new empty object on every render
37
- const sourcesValue = useMemo(() => props.sources ?? {}, [props.sources])
51
+ // Memoize resources to prevent creating a new empty object on every render
52
+ const resourcesValue = useMemo(() => props.resources ?? {}, [props.resources])
38
53
 
39
54
  // Create a nested structure of ResourceProviders for each config
40
55
  const createNestedProviders = (index: number): ReactElement => {
41
56
  if (index >= configs.length) {
42
57
  return (
43
58
  <AuthBoundary {...props} projectIds={projectIds}>
44
- <SourcesContext.Provider value={sourcesValue}>{children}</SourcesContext.Provider>
59
+ <ResourcesContext.Provider value={resourcesValue}>{children}</ResourcesContext.Provider>
45
60
  </AuthBoundary>
46
61
  )
47
62
  }
@@ -53,5 +68,18 @@ export function SDKProvider({
53
68
  )
54
69
  }
55
70
 
56
- return createNestedProviders(0)
71
+ return (
72
+ <ErrorBoundary FallbackComponent={ChunkAwareFallback}>
73
+ <ResetChunkReloadFlagOnMount />
74
+ {createNestedProviders(0)}
75
+ </ErrorBoundary>
76
+ )
77
+ }
78
+
79
+ function ChunkAwareFallback(fallbackProps: FallbackProps): ReactElement {
80
+ if (isImportError(fallbackProps.error)) {
81
+ return <ChunkLoadError {...fallbackProps} />
82
+ }
83
+ // Re-throw so downstream boundaries (e.g. AuthBoundary) handle other errors.
84
+ throw fallbackProps.error
57
85
  }
@@ -1,4 +1,4 @@
1
- import {type DocumentSource, isStudioConfig, type SanityConfig} from '@sanity/sdk'
1
+ import {type DocumentResource, isStudioConfig, type SanityConfig} from '@sanity/sdk'
2
2
  import {type ReactElement, useContext, useEffect, useMemo} from 'react'
3
3
 
4
4
  import {SDKStudioContext, type StudioWorkspaceHandle} from '../context/SDKStudioContext'
@@ -19,7 +19,7 @@ export interface SanityAppProps {
19
19
  config?: SanityConfig | SanityConfig[]
20
20
  /** @deprecated use the `config` prop instead. */
21
21
  sanityConfigs?: SanityConfig[]
22
- sources?: Record<string, DocumentSource>
22
+ resources?: Record<string, DocumentResource>
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
@@ -1,5 +1,5 @@
1
1
  import {CorsOriginError} from '@sanity/client'
2
- import {AuthStateType, getCorsErrorProjectId, isStudioConfig} from '@sanity/sdk'
2
+ import {AuthStateType, getCorsErrorProjectId, isImportError, isStudioConfig} from '@sanity/sdk'
3
3
  import {useEffect, useMemo} from 'react'
4
4
  import {ErrorBoundary, type FallbackProps} from 'react-error-boundary'
5
5
 
@@ -8,6 +8,7 @@ import {useAuthState} from '../../hooks/auth/useAuthState'
8
8
  import {useLoginUrl} from '../../hooks/auth/useLoginUrl'
9
9
  import {useVerifyOrgProjects} from '../../hooks/auth/useVerifyOrgProjects'
10
10
  import {useSanityInstance} from '../../hooks/context/useSanityInstance'
11
+ import {ChunkLoadError} from '../errors/ChunkLoadError'
11
12
  import {CorsErrorComponent} from '../errors/CorsErrorComponent'
12
13
  import {isInIframe} from '../utils'
13
14
  import {AuthError} from './AuthError'
@@ -110,6 +111,12 @@ export function AuthBoundary({
110
111
  }: AuthBoundaryProps): React.ReactNode {
111
112
  const FallbackComponent = useMemo(() => {
112
113
  return function LoginComponentWithLayoutProps(fallbackProps: FallbackProps) {
114
+ // Chunk-load errors from any lazy-loaded code beneath this boundary
115
+ // (typically the consumer's app) get the chunk-aware fallback instead
116
+ // of being misreported as auth errors.
117
+ if (isImportError(fallbackProps.error)) {
118
+ return <ChunkLoadError {...fallbackProps} />
119
+ }
113
120
  if (fallbackProps.error instanceof CorsOriginError) {
114
121
  return (
115
122
  <CorsErrorComponent
@@ -0,0 +1,37 @@
1
+ import {SDK_CHANNEL_NAME, SDK_NODE_NAME} from '@sanity/message-protocol'
2
+ import {useEffect} from 'react'
3
+
4
+ import {useWindowConnection} from '../../hooks/comlink/useWindowConnection'
5
+
6
+ interface DashboardAccessRequestProps {
7
+ projectId: string
8
+ }
9
+
10
+ /**
11
+ * Sends a `dashboard/v1/auth/access/request` message to the dashboard via
12
+ * comlink so the user can request access to a project they don't belong to.
13
+ *
14
+ * This is intentionally isolated in its own component because
15
+ * `useWindowConnection` suspends until a comlink node is available, which
16
+ * never happens outside the dashboard. Callers must gate rendering on
17
+ * `getIsInDashboardState(...).getCurrent()` and wrap this in a
18
+ * {@link https://react.dev/reference/react/Suspense | Suspense} boundary
19
+ * so the suspension stays local instead of bubbling up to the app shell.
20
+ *
21
+ * @internal
22
+ */
23
+ export function DashboardAccessRequest({projectId}: DashboardAccessRequestProps): null {
24
+ const {fetch} = useWindowConnection({
25
+ name: SDK_NODE_NAME,
26
+ connectTo: SDK_CHANNEL_NAME,
27
+ })
28
+
29
+ useEffect(() => {
30
+ fetch('dashboard/v1/auth/access/request', {
31
+ resourceType: 'project',
32
+ resourceId: projectId,
33
+ })
34
+ }, [fetch, projectId])
35
+
36
+ return null
37
+ }
@@ -1,19 +1,51 @@
1
+ import {ClientError} from '@sanity/client'
2
+ import {getIsInDashboardState} from '@sanity/sdk'
1
3
  import {fireEvent, render, screen, waitFor} from '@testing-library/react'
2
- import {describe, expect, it, vi} from 'vitest'
4
+ import {afterEach, beforeEach, describe, expect, it, type Mock, vi} from 'vitest'
3
5
 
4
6
  import {ResourceProvider} from '../../context/ResourceProvider'
5
7
  import {AuthError} from './AuthError'
6
8
  import {LoginError} from './LoginError'
7
9
 
10
+ vi.mock('@sanity/sdk', async () => {
11
+ const actual = await vi.importActual('@sanity/sdk')
12
+ return {
13
+ ...actual,
14
+ getIsInDashboardState: vi.fn(() => ({getCurrent: vi.fn(() => false)})),
15
+ }
16
+ })
17
+
18
+ const mockLogout = vi.fn(async () => {})
8
19
  vi.mock('../../hooks/auth/useLogOut', () => ({
9
- useLogOut: vi.fn(() => async () => {}),
20
+ useLogOut: vi.fn(() => mockLogout),
10
21
  }))
11
22
 
23
+ const mockWindowConnectionFetch = vi.fn()
12
24
  vi.mock('../../hooks/comlink/useWindowConnection', () => ({
13
- useWindowConnection: vi.fn(() => ({fetch: vi.fn()})),
25
+ useWindowConnection: vi.fn(() => ({fetch: mockWindowConnectionFetch})),
14
26
  }))
15
27
 
28
+ const mockGetIsInDashboardState = getIsInDashboardState as Mock
29
+
30
+ function makeClientError(statusCode: number, body: unknown): ClientError {
31
+ return new ClientError({
32
+ statusCode,
33
+ headers: {},
34
+ body,
35
+ url: 'https://example.test',
36
+ method: 'GET',
37
+ } as ConstructorParameters<typeof ClientError>[0])
38
+ }
39
+
16
40
  describe('LoginError', () => {
41
+ beforeEach(() => {
42
+ mockGetIsInDashboardState.mockReturnValue({getCurrent: vi.fn(() => false)})
43
+ })
44
+
45
+ afterEach(() => {
46
+ vi.clearAllMocks()
47
+ })
48
+
17
49
  it('shows authentication error and retry button', async () => {
18
50
  const mockReset = vi.fn()
19
51
  const error = new AuthError(new Error('Test error'))
@@ -38,7 +70,6 @@ describe('LoginError', () => {
38
70
  const mockReset = vi.fn()
39
71
  const nonAuthError = new Error('Non-auth error')
40
72
 
41
- // Suppress console.error during this test
42
73
  const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
43
74
 
44
75
  expect(() => {
@@ -49,6 +80,161 @@ describe('LoginError', () => {
49
80
  )
50
81
  }).toThrow('Non-auth error')
51
82
 
52
- consoleErrorSpy.mockRestore() // Restore original console.error behavior
83
+ consoleErrorSpy.mockRestore()
84
+ })
85
+
86
+ // In a standalone app (not embedded in the dashboard) the dashboard access
87
+ // request path must not render, because useWindowConnection would suspend
88
+ // waiting for a comlink node that never arrives.
89
+ it('renders synchronously on a 401 projectUserNotFound error outside the dashboard', async () => {
90
+ mockGetIsInDashboardState.mockReturnValue({getCurrent: vi.fn(() => false)})
91
+
92
+ const error = makeClientError(401, {
93
+ error: {
94
+ type: 'projectUserNotFoundError',
95
+ description: 'User is not a member of this project.',
96
+ },
97
+ })
98
+
99
+ render(
100
+ <ResourceProvider fallback={<div>SUSPENDED</div>}>
101
+ <LoginError error={error} resetErrorBoundary={vi.fn()} />
102
+ </ResourceProvider>,
103
+ )
104
+
105
+ await waitFor(() => {
106
+ expect(screen.getByText('User is not a member of this project.')).toBeInTheDocument()
107
+ })
108
+ // ClientError must render under the "Authentication Error" heading; it is
109
+ // not a ConfigurationError.
110
+ expect(screen.getByText('Authentication Error')).toBeInTheDocument()
111
+ expect(screen.queryByText('Configuration Error')).not.toBeInTheDocument()
112
+ expect(screen.queryByText('SUSPENDED')).not.toBeInTheDocument()
113
+ expect(mockWindowConnectionFetch).not.toHaveBeenCalled()
114
+ })
115
+
116
+ it('fires the dashboard access request on a 401 projectUserNotFound error inside the dashboard', async () => {
117
+ mockGetIsInDashboardState.mockReturnValue({getCurrent: vi.fn(() => true)})
118
+
119
+ const error = makeClientError(401, {
120
+ error: {
121
+ type: 'projectUserNotFoundError',
122
+ description: 'User is not a member of this project.',
123
+ },
124
+ })
125
+
126
+ render(
127
+ <ResourceProvider projectId="abc123" dataset="production" fallback={<div>SUSPENDED</div>}>
128
+ <LoginError error={error} resetErrorBoundary={vi.fn()} />
129
+ </ResourceProvider>,
130
+ )
131
+
132
+ await waitFor(() => {
133
+ expect(mockWindowConnectionFetch).toHaveBeenCalledWith('dashboard/v1/auth/access/request', {
134
+ resourceType: 'project',
135
+ resourceId: 'abc123',
136
+ })
137
+ })
138
+ })
139
+
140
+ // Mirrors the real production chain: AuthBoundary wraps the ClientError in
141
+ // an AuthError before the error boundary hands it to LoginError. The
142
+ // `.cause` unwrap is what makes the dashboard access request path reachable
143
+ // at runtime (without it, the previous `error instanceof ClientError` check
144
+ // was dead code in the dashboard).
145
+ it('fires the dashboard access request when the projectUserNotFound ClientError is wrapped in an AuthError', async () => {
146
+ mockGetIsInDashboardState.mockReturnValue({getCurrent: vi.fn(() => true)})
147
+
148
+ const clientError = makeClientError(401, {
149
+ error: {
150
+ type: 'projectUserNotFoundError',
151
+ description: 'User is not a member of this project.',
152
+ },
153
+ })
154
+ const error = new AuthError(clientError)
155
+ const mockReset = vi.fn()
156
+
157
+ render(
158
+ <ResourceProvider projectId="abc123" dataset="production" fallback={<div>SUSPENDED</div>}>
159
+ <LoginError error={error} resetErrorBoundary={mockReset} />
160
+ </ResourceProvider>,
161
+ )
162
+
163
+ await waitFor(() => {
164
+ expect(mockWindowConnectionFetch).toHaveBeenCalledWith('dashboard/v1/auth/access/request', {
165
+ resourceType: 'project',
166
+ resourceId: 'abc123',
167
+ })
168
+ })
169
+
170
+ expect(screen.getByText('Authentication Error')).toBeInTheDocument()
171
+ expect(screen.getByText('User is not a member of this project.')).toBeInTheDocument()
172
+ // projectUserNotFound intentionally hides the Retry CTA: the user can't
173
+ // fix it by retrying, only by getting access granted through the
174
+ // dashboard access request flow above.
175
+ expect(screen.queryByRole('button', {name: 'Retry'})).not.toBeInTheDocument()
176
+ // Dashboard flow must never auto-log-out; ComlinkTokenRefreshProvider is
177
+ // responsible for any token mutation, not LoginError.
178
+ expect(mockLogout).not.toHaveBeenCalled()
179
+ expect(mockReset).not.toHaveBeenCalled()
180
+ })
181
+
182
+ // In a standalone app, an invalid-token 401 (anything other than
183
+ // `projectUserNotFoundError`) should silently log the user out so that
184
+ // AuthBoundary's LOGGED_OUT effect redirects to the Sanity login URL.
185
+ // AuthBoundary wraps the real ClientError in an AuthError before it reaches
186
+ // the error boundary, so the component must unwrap `.cause` to see it.
187
+ it('auto-logs-out on a non-projectUserNotFound 401 outside the dashboard', async () => {
188
+ mockGetIsInDashboardState.mockReturnValue({getCurrent: vi.fn(() => false)})
189
+
190
+ const mockReset = vi.fn()
191
+ const clientError = makeClientError(401, {
192
+ error: {type: 'someOther401Type', description: 'Token is invalid'},
193
+ })
194
+ const error = new AuthError(clientError)
195
+
196
+ render(
197
+ <ResourceProvider fallback={null}>
198
+ <LoginError error={error} resetErrorBoundary={mockReset} />
199
+ </ResourceProvider>,
200
+ )
201
+
202
+ expect(screen.getByText('Authentication Error')).toBeInTheDocument()
203
+ expect(await screen.findByText('Signing you out and returning to login...')).toBeInTheDocument()
204
+ await waitFor(() => {
205
+ expect(mockLogout).toHaveBeenCalled()
206
+ })
207
+ await waitFor(() => {
208
+ expect(mockReset).toHaveBeenCalled()
209
+ })
210
+ })
211
+
212
+ // In the dashboard we must not auto-log-out on a generic 401.
213
+ // ComlinkTokenRefreshProvider is responsible for asking the parent window
214
+ // for a fresh token; the Retry button stays as a manual fallback.
215
+ it('does not auto-log-out on a non-projectUserNotFound 401 inside the dashboard', async () => {
216
+ mockGetIsInDashboardState.mockReturnValue({getCurrent: vi.fn(() => true)})
217
+
218
+ const mockReset = vi.fn()
219
+ const clientError = makeClientError(401, {
220
+ error: {type: 'someOther401Type', description: 'Token is invalid'},
221
+ })
222
+ const error = new AuthError(clientError)
223
+
224
+ render(
225
+ <ResourceProvider projectId="abc123" dataset="production" fallback={null}>
226
+ <LoginError error={error} resetErrorBoundary={mockReset} />
227
+ </ResourceProvider>,
228
+ )
229
+
230
+ expect(screen.getByText('Authentication Error')).toBeInTheDocument()
231
+ expect(
232
+ screen.getByText('Please try again or contact support if the problem persists.'),
233
+ ).toBeInTheDocument()
234
+ expect(screen.getByRole('button', {name: 'Retry'})).toBeInTheDocument()
235
+ expect(mockLogout).not.toHaveBeenCalled()
236
+ expect(mockReset).not.toHaveBeenCalled()
237
+ // Generic 401s should not trigger the dashboard access request flow.
238
+ expect(mockWindowConnectionFetch).not.toHaveBeenCalled()
53
239
  })
54
240
  })
@@ -1,21 +1,21 @@
1
1
  import {ClientError} from '@sanity/client'
2
- import {SDK_CHANNEL_NAME, SDK_NODE_NAME} from '@sanity/message-protocol'
3
2
  import {
4
3
  AuthStateType,
5
4
  getClientErrorApiBody,
6
5
  getClientErrorApiDescription,
6
+ getIsInDashboardState,
7
7
  isProjectUserNotFoundClientError,
8
8
  } from '@sanity/sdk'
9
- import {useCallback, useEffect, useState} from 'react'
9
+ import {Suspense, useCallback, useEffect, useMemo, useRef} from 'react'
10
10
  import {type FallbackProps} from 'react-error-boundary'
11
11
 
12
12
  import {useAuthState} from '../../hooks/auth/useAuthState'
13
13
  import {useLogOut} from '../../hooks/auth/useLogOut'
14
- import {useWindowConnection} from '../../hooks/comlink/useWindowConnection'
15
14
  import {useSanityInstance} from '../../hooks/context/useSanityInstance'
16
15
  import {Error} from '../errors/Error'
17
16
  import {AuthError} from './AuthError'
18
17
  import {ConfigurationError} from './ConfigurationError'
18
+ import {DashboardAccessRequest} from './DashboardAccessRequest'
19
19
  /**
20
20
  * @alpha
21
21
  */
@@ -39,75 +39,119 @@ export function LoginError({error, resetErrorBoundary}: LoginErrorProps): React.
39
39
 
40
40
  const logout = useLogOut()
41
41
  const authState = useAuthState()
42
+ const instance = useSanityInstance()
42
43
  const {
43
44
  config: {projectId},
44
- } = useSanityInstance()
45
+ } = instance
45
46
 
46
- const [authErrorMessage, setAuthErrorMessage] = useState(
47
- 'Please try again or contact support if the problem persists.',
48
- )
49
- const [showRetryCta, setShowRetryCta] = useState(true)
47
+ // Errors surfaced through `AuthBoundary` arrive wrapped in `AuthError`, with
48
+ // the original `ClientError` tucked under `.cause`. Unwrapping it here lets
49
+ // the 401/404 branches below respond to the real status code instead of
50
+ // silently skipping because `error instanceof ClientError` is false.
51
+ const clientError: ClientError | null =
52
+ error instanceof ClientError
53
+ ? error
54
+ : error instanceof AuthError && error.cause instanceof ClientError
55
+ ? error.cause
56
+ : null
57
+
58
+ const isInDashboard = getIsInDashboardState(instance).getCurrent()
50
59
 
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
- })
60
+ const isProjectUserNotFound =
61
+ !!clientError && clientError.statusCode === 401 && isProjectUserNotFoundClientError(clientError)
62
+
63
+ // The dashboard access request flow relies on a comlink connection to the
64
+ // parent window. In standalone apps that connection never materializes, so
65
+ // 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
67
+ // render the child with a single non-null guard.
68
+ const dashboardAccessProjectId =
69
+ isProjectUserNotFound && projectId && isInDashboard ? projectId : null
58
70
 
59
71
  const handleRetry = useCallback(async () => {
60
72
  await logout()
61
73
  resetErrorBoundary()
62
74
  }, [logout, resetErrorBoundary])
63
75
 
64
- useEffect(() => {
65
- if (error instanceof ClientError) {
66
- if (error.statusCode === 401) {
67
- // Surface a friendly message for projectUserNotFoundError (do not logout/refresh)
68
- if (isProjectUserNotFoundClientError(error)) {
69
- const description = getClientErrorApiDescription(error)
70
- if (description) setAuthErrorMessage(description)
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
- })
79
- } else {
80
- setShowRetryCta(true)
81
- handleRetry()
82
- }
83
- } else if (error.statusCode === 404) {
84
- const errorMessage = getClientErrorApiBody(error)?.message || ''
85
- if (errorMessage.startsWith('Session with sid') && errorMessage.endsWith('not found')) {
86
- setAuthErrorMessage('The session ID is invalid or expired.')
87
- } else {
88
- setAuthErrorMessage('The login link is invalid or expired. Please try again.')
76
+ // Display state is fully derived from the inputs above, so we don't need
77
+ // to mirror it through useState/useEffect.
78
+ const {authErrorMessage, showRetryCta} = useMemo(() => {
79
+ let message = 'Please try again or contact support if the problem persists.'
80
+ let retry = true
81
+
82
+ if (clientError) {
83
+ if (clientError.statusCode === 401) {
84
+ if (isProjectUserNotFound) {
85
+ const description = getClientErrorApiDescription(clientError)
86
+ if (description) message = description
87
+ retry = false
88
+ } else if (!isInDashboard) {
89
+ message = 'Signing you out and returning to login...'
90
+ retry = true
89
91
  }
90
- setShowRetryCta(true)
92
+ // Dashboard non-projectUserNotFound 401: leave the current UI in place
93
+ // and let ComlinkTokenRefreshProvider request a fresh token from the
94
+ // parent window. The Retry button remains as a manual fallback.
95
+ } else if (clientError.statusCode === 404) {
96
+ const errorMessage = getClientErrorApiBody(clientError)?.message || ''
97
+ message =
98
+ errorMessage.startsWith('Session with sid') && errorMessage.endsWith('not found')
99
+ ? 'The session ID is invalid or expired.'
100
+ : 'The login link is invalid or expired. Please try again.'
101
+ retry = true
91
102
  }
92
103
  }
93
104
  if (authState.type !== AuthStateType.ERROR && error instanceof ConfigurationError) {
94
- setAuthErrorMessage(error.message)
95
- setShowRetryCta(true)
105
+ message = error.message
106
+ retry = true
96
107
  }
97
- }, [authState, handleRetry, error, fetch, projectId])
108
+ return {authErrorMessage: message, showRetryCta: retry}
109
+ }, [authState, clientError, error, isInDashboard, isProjectUserNotFound])
110
+
111
+ // Guards against re-entering the standalone auto-logout branch below. Once
112
+ // `logout()` flips the auth store to LOGGED_OUT, `useAuthState` emits a new
113
+ // `authState` reference and re-runs this effect; without the ref we'd call
114
+ // `handleRetry` again on every emission and React eventually aborts with
115
+ // "Maximum update depth exceeded", leaving a blank page.
116
+ const hasAutoLoggedOutRef = useRef(false)
117
+
118
+ // Standalone apps: the token is bad and there's no parent window to mint a
119
+ // new one, so log the user out and let `AuthBoundary`'s LOGGED_OUT effect
120
+ // redirect to the Sanity login URL.
121
+ useEffect(() => {
122
+ if (
123
+ clientError &&
124
+ clientError.statusCode === 401 &&
125
+ !isProjectUserNotFound &&
126
+ !isInDashboard &&
127
+ !hasAutoLoggedOutRef.current
128
+ ) {
129
+ hasAutoLoggedOutRef.current = true
130
+ handleRetry()
131
+ }
132
+ }, [clientError, handleRetry, isInDashboard, isProjectUserNotFound])
98
133
 
99
134
  return (
100
- <Error
101
- heading={error instanceof AuthError ? 'Authentication Error' : 'Configuration Error'}
102
- description={authErrorMessage}
103
- cta={
104
- showRetryCta
105
- ? {
106
- text: 'Retry',
107
- onClick: handleRetry,
108
- }
109
- : undefined
110
- }
111
- />
135
+ <>
136
+ {dashboardAccessProjectId && (
137
+ <Suspense fallback={null}>
138
+ <DashboardAccessRequest projectId={dashboardAccessProjectId} />
139
+ </Suspense>
140
+ )}
141
+ <Error
142
+ heading={
143
+ error instanceof ConfigurationError ? 'Configuration Error' : 'Authentication Error'
144
+ }
145
+ description={authErrorMessage}
146
+ cta={
147
+ showRetryCta
148
+ ? {
149
+ text: 'Retry',
150
+ onClick: handleRetry,
151
+ }
152
+ : undefined
153
+ }
154
+ />
155
+ </>
112
156
  )
113
157
  }