@sanity/sdk-react 3.0.0-rc.0 → 3.0.0-rc.2
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 +103 -155
- package/dist/index.d.ts +2488 -2019
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4435 -1529
- package/dist/index.js.map +1 -1
- package/package.json +55 -59
- package/src/_exports/index.ts +2 -4
- package/src/_exports/sdk-react.ts +30 -18
- package/src/components/SDKProvider.test.tsx +173 -32
- package/src/components/SDKProvider.tsx +87 -28
- package/src/components/SanityApp.test.tsx +68 -12
- package/src/components/SanityApp.tsx +93 -87
- package/src/components/auth/AuthBoundary.recovery.test.tsx +86 -0
- package/src/components/auth/AuthBoundary.test.tsx +7 -7
- package/src/components/auth/AuthBoundary.tsx +29 -6
- package/src/components/auth/DashboardAccessRequest.tsx +37 -0
- package/src/components/auth/LoginError.test.tsx +191 -5
- package/src/components/auth/LoginError.tsx +104 -61
- package/src/components/errors/ChunkLoadError.test.tsx +59 -0
- package/src/components/errors/ChunkLoadError.tsx +56 -0
- package/src/components/errors/CorsErrorComponent.tsx +2 -2
- package/src/components/errors/chunkReloadStorage.ts +57 -0
- package/src/config/handles.ts +29 -27
- package/src/constants.ts +5 -0
- package/src/context/ComlinkTokenRefresh.test.tsx +10 -27
- package/src/context/ComlinkTokenRefresh.tsx +3 -5
- package/src/context/DefaultResourceContext.ts +3 -3
- package/src/context/OrganizationResourcesProvider.test.tsx +189 -0
- package/src/context/OrganizationResourcesProvider.tsx +111 -0
- package/src/context/PerspectiveContext.ts +3 -3
- package/src/context/ProjectContext.ts +15 -0
- package/src/context/ResourceProvider.test.tsx +93 -90
- package/src/context/ResourceProvider.tsx +62 -93
- package/src/context/SDKStudioContext.test.tsx +28 -33
- package/src/context/SanityInstanceProvider.test.tsx +100 -0
- package/src/context/SanityInstanceProvider.tsx +71 -0
- package/src/context/WorkbenchTokenRefresh.test.tsx +106 -0
- package/src/context/WorkbenchTokenRefresh.tsx +61 -0
- package/src/context/renderSanityApp.test.tsx +151 -49
- package/src/context/renderSanityApp.tsx +12 -8
- package/src/context/workbenchToken.ts +63 -0
- package/src/hooks/access/useCheckPermissions.test-d.ts +15 -0
- package/src/hooks/access/useCheckPermissions.test.tsx +53 -0
- package/src/hooks/access/useCheckPermissions.ts +24 -0
- package/src/hooks/agent/agentActions.test.tsx +2 -2
- package/src/hooks/agent/agentActions.ts +52 -72
- package/src/hooks/agent/useAgentResourceContext.ts +1 -1
- package/src/hooks/applications/useApplication.test-d.ts +30 -0
- package/src/hooks/applications/useApplication.ts +22 -0
- package/src/hooks/applications/useApplications.test-d.ts +31 -0
- package/src/hooks/applications/useApplications.ts +25 -0
- package/src/hooks/applications/useDeleteApplication.test-d.ts +12 -0
- package/src/hooks/applications/useDeleteApplication.ts +11 -0
- package/src/hooks/applications/useUpdateApplication.test-d.ts +12 -0
- package/src/hooks/applications/useUpdateApplication.ts +11 -0
- package/src/hooks/auth/useDashboardOrganizationId.test.tsx +2 -8
- package/src/hooks/auth/useVerifyOrgProjects.test.tsx +8 -32
- package/src/hooks/auth/useVerifyOrgProjects.tsx +13 -6
- package/src/hooks/client/useClient.test.tsx +1 -4
- package/src/hooks/client/useClient.ts +1 -0
- package/src/hooks/comlink/useFrameConnection.test.tsx +3 -2
- package/src/hooks/comlink/useFrameConnection.ts +1 -1
- package/src/hooks/comlink/useWindowConnection.test.tsx +4 -3
- package/src/hooks/comlink/useWindowConnection.ts +2 -3
- package/src/hooks/comments/useCommentActions.test.tsx +150 -0
- package/src/hooks/comments/useCommentActions.ts +109 -0
- package/src/hooks/comments/useCommentList.ts +79 -0
- package/src/hooks/comments/useCommentThreads.test.tsx +107 -0
- package/src/hooks/comments/useCommentThreads.ts +73 -0
- package/src/hooks/comments/useComments.test.tsx +242 -0
- package/src/hooks/comments/useComments.ts +59 -0
- package/src/hooks/context/useResource.test.tsx +32 -0
- package/src/hooks/context/useResource.ts +24 -0
- package/src/hooks/context/useSanityInstance.test.tsx +18 -1
- package/src/hooks/context/useSanityInstance.ts +6 -6
- package/src/hooks/dashboard/useManageFavorite.test.tsx +110 -46
- package/src/hooks/dashboard/useManageFavorite.ts +11 -17
- package/src/hooks/dashboard/useNavigateToStudioDocument.test.ts +5 -7
- package/src/hooks/dashboard/useNavigateToStudioDocument.ts +2 -6
- package/src/hooks/dashboard/useRecordDocumentHistoryEvent.test.ts +0 -2
- package/src/hooks/dashboard/useRecordDocumentHistoryEvent.ts +2 -2
- package/src/hooks/dashboard/useWindowTitle.test.ts +244 -0
- package/src/hooks/dashboard/useWindowTitle.ts +114 -0
- package/src/hooks/datasets/useDatasets.test.tsx +116 -0
- package/src/hooks/datasets/useDatasets.ts +33 -13
- package/src/hooks/document/useApplyDocumentActions.test.tsx +48 -0
- package/src/hooks/document/useApplyDocumentActions.ts +66 -48
- package/src/hooks/document/useCreateDocument.test.tsx +83 -0
- package/src/hooks/document/useCreateDocument.ts +117 -0
- package/src/hooks/document/useDocument.test.tsx +37 -8
- package/src/hooks/document/useDocument.ts +131 -66
- package/src/hooks/document/useDocumentEvent.test.tsx +16 -4
- package/src/hooks/document/useDocumentEvent.ts +16 -10
- package/src/hooks/document/useDocumentPermissions.test.tsx +8 -7
- package/src/hooks/document/useDocumentPermissions.ts +71 -39
- package/src/hooks/document/useDocumentSyncStatus.test.ts +1 -0
- package/src/hooks/document/useDocumentSyncStatus.ts +13 -9
- package/src/hooks/document/useEditDocument.test.tsx +21 -14
- package/src/hooks/document/useEditDocument.ts +163 -17
- package/src/hooks/documents/useDocuments.test.tsx +64 -44
- package/src/hooks/documents/useDocuments.ts +31 -20
- package/src/hooks/helpers/createCallbackHook.test.tsx +13 -19
- package/src/hooks/helpers/createFetcherHook.test.tsx +180 -0
- package/src/hooks/helpers/createFetcherHook.ts +69 -0
- package/src/hooks/helpers/createMutationHook.test.tsx +125 -0
- package/src/hooks/helpers/createMutationHook.tsx +93 -0
- package/src/hooks/helpers/createStateSourceHook.test.tsx +10 -10
- package/src/hooks/helpers/createStateSourceHook.tsx +2 -1
- package/src/hooks/{document/useApplyDocumentActions.test.ts → helpers/useApplyActions.test.tsx} +92 -53
- package/src/hooks/helpers/useApplyActions.ts +68 -0
- package/src/hooks/helpers/useNormalizedResourceOptions.test.tsx +226 -0
- package/src/hooks/helpers/useNormalizedResourceOptions.ts +79 -48
- package/src/hooks/helpers/useResolvedProjectId.test.tsx +59 -0
- package/src/hooks/helpers/useResolvedProjectId.ts +35 -0
- package/src/hooks/helpers/useTrackHookUsage.ts +37 -0
- package/src/hooks/installations/useInstallation.test-d.ts +19 -0
- package/src/hooks/installations/useInstallation.ts +22 -0
- package/src/hooks/installations/useInstallations.test-d.ts +26 -0
- package/src/hooks/installations/useInstallations.ts +25 -0
- package/src/hooks/organizations/useOrganization.test-d.ts +60 -0
- package/src/hooks/organizations/useOrganization.test.ts +63 -0
- package/src/hooks/organizations/useOrganization.ts +34 -0
- package/src/hooks/organizations/useOrganizations.test-d.ts +68 -0
- package/src/hooks/organizations/useOrganizations.test.ts +63 -0
- package/src/hooks/organizations/useOrganizations.ts +39 -0
- package/src/hooks/paginatedDocuments/usePaginatedDocuments.test.tsx +34 -27
- package/src/hooks/paginatedDocuments/usePaginatedDocuments.ts +35 -22
- package/src/hooks/presence/usePresence.test.tsx +0 -15
- package/src/hooks/presence/usePresence.ts +22 -20
- package/src/hooks/presence/usePresenceForDocument.test.tsx +141 -0
- package/src/hooks/presence/usePresenceForDocument.ts +104 -0
- package/src/hooks/presence/useReportPresence.test.tsx +202 -0
- package/src/hooks/presence/useReportPresence.ts +170 -0
- package/src/hooks/preview/useDocumentPreview.test.tsx +0 -1
- package/src/hooks/preview/useDocumentPreview.tsx +12 -14
- package/src/hooks/projection/useDocumentProjection.test.tsx +78 -9
- package/src/hooks/projection/useDocumentProjection.ts +81 -2
- package/src/hooks/projects/useProject.test-d.ts +56 -0
- package/src/hooks/projects/useProject.test.tsx +120 -0
- package/src/hooks/projects/useProject.ts +42 -43
- package/src/hooks/projects/useProjects.test-d.ts +60 -0
- package/src/hooks/projects/useProjects.test.ts +45 -98
- package/src/hooks/projects/useProjects.ts +17 -32
- package/src/hooks/query/useQuery.test.tsx +6 -5
- package/src/hooks/query/useQuery.ts +94 -17
- package/src/hooks/releases/useActiveReleases.test.tsx +13 -7
- package/src/hooks/releases/useActiveReleases.ts +28 -24
- package/src/hooks/releases/useAllReleases.test.tsx +93 -0
- package/src/hooks/releases/useAllReleases.ts +62 -0
- package/src/hooks/releases/useApplyReleaseActions.test.tsx +66 -0
- package/src/hooks/releases/useApplyReleaseActions.ts +82 -0
- package/src/hooks/releases/usePerspective.test.tsx +18 -10
- package/src/hooks/releases/usePerspective.ts +13 -17
- package/src/hooks/users/useUser.test.tsx +3 -9
- package/src/hooks/users/useUser.ts +4 -8
- package/src/hooks/users/useUsers.test.tsx +103 -7
- package/src/hooks/users/useUsers.ts +38 -5
- package/src/utils/resolveOrgResources.test.ts +111 -0
- package/src/utils/resolveOrgResources.ts +69 -0
- package/src/hooks/context/useDefaultResource.test.tsx +0 -25
- package/src/hooks/context/useDefaultResource.ts +0 -30
- package/src/hooks/dashboard/useDispatchIntent.test.ts +0 -254
- package/src/hooks/dashboard/useDispatchIntent.ts +0 -157
- package/src/hooks/dashboard/utils/useResourceIdFromDocumentHandle.test.ts +0 -107
- package/src/hooks/dashboard/utils/useResourceIdFromDocumentHandle.ts +0 -39
- package/src/hooks/datasets/useDatasets.test.ts +0 -66
- package/src/hooks/helpers/useNormalizedResourceOptions.test.ts +0 -65
- package/src/hooks/projects/useProject.test.ts +0 -79
|
@@ -148,7 +148,7 @@ describe('AuthBoundary', () => {
|
|
|
148
148
|
isExchangingToken: false,
|
|
149
149
|
})
|
|
150
150
|
const {container} = render(
|
|
151
|
-
<ResourceProvider
|
|
151
|
+
<ResourceProvider projectId="p" dataset="d" fallback={null}>
|
|
152
152
|
<AuthBoundary projectIds={testProjectIds}>Protected Content</AuthBoundary>
|
|
153
153
|
</ResourceProvider>,
|
|
154
154
|
)
|
|
@@ -165,7 +165,7 @@ describe('AuthBoundary', () => {
|
|
|
165
165
|
token: 'exampleToken',
|
|
166
166
|
})
|
|
167
167
|
render(
|
|
168
|
-
<ResourceProvider
|
|
168
|
+
<ResourceProvider projectId="p" dataset="d" fallback={null}>
|
|
169
169
|
<AuthBoundary projectIds={testProjectIds}>Protected Content</AuthBoundary>
|
|
170
170
|
</ResourceProvider>,
|
|
171
171
|
)
|
|
@@ -179,7 +179,7 @@ describe('AuthBoundary', () => {
|
|
|
179
179
|
error: new Error('test error'),
|
|
180
180
|
})
|
|
181
181
|
render(
|
|
182
|
-
<ResourceProvider
|
|
182
|
+
<ResourceProvider projectId="p" dataset="d" fallback={null}>
|
|
183
183
|
<AuthBoundary projectIds={testProjectIds}>Protected Content</AuthBoundary>
|
|
184
184
|
</ResourceProvider>,
|
|
185
185
|
)
|
|
@@ -196,7 +196,7 @@ describe('AuthBoundary', () => {
|
|
|
196
196
|
|
|
197
197
|
it('renders children when logged in and org verification passes', () => {
|
|
198
198
|
render(
|
|
199
|
-
<ResourceProvider
|
|
199
|
+
<ResourceProvider projectId="p" dataset="d" fallback={null}>
|
|
200
200
|
<AuthBoundary projectIds={testProjectIds}>Protected Content</AuthBoundary>
|
|
201
201
|
</ResourceProvider>,
|
|
202
202
|
)
|
|
@@ -218,7 +218,7 @@ describe('AuthBoundary', () => {
|
|
|
218
218
|
|
|
219
219
|
// Need to catch the error thrown during render. ErrorBoundary mock handles this.
|
|
220
220
|
render(
|
|
221
|
-
<ResourceProvider
|
|
221
|
+
<ResourceProvider projectId="p" dataset="d" fallback={null}>
|
|
222
222
|
<AuthBoundary verifyOrganization={true} projectIds={testProjectIds}>
|
|
223
223
|
<div>Protected Content</div>
|
|
224
224
|
</AuthBoundary>
|
|
@@ -250,7 +250,7 @@ describe('AuthBoundary', () => {
|
|
|
250
250
|
})
|
|
251
251
|
|
|
252
252
|
render(
|
|
253
|
-
<ResourceProvider
|
|
253
|
+
<ResourceProvider projectId="p" dataset="d" fallback={null}>
|
|
254
254
|
<AuthBoundary verifyOrganization={false} projectIds={testProjectIds}>
|
|
255
255
|
<div>Protected Content</div>
|
|
256
256
|
</AuthBoundary>
|
|
@@ -275,7 +275,7 @@ describe('AuthBoundary', () => {
|
|
|
275
275
|
mockUseVerifyOrgProjects.mockImplementation(() => null)
|
|
276
276
|
|
|
277
277
|
render(
|
|
278
|
-
<ResourceProvider
|
|
278
|
+
<ResourceProvider projectId="p" dataset="d" fallback={null}>
|
|
279
279
|
<AuthBoundary projectIds={testProjectIds}>
|
|
280
280
|
<div>Protected Content</div>
|
|
281
281
|
</AuthBoundary>
|
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import {CorsOriginError} from '@sanity/client'
|
|
2
|
-
import {AuthStateType, getCorsErrorProjectId,
|
|
2
|
+
import {AuthStateType, getCorsErrorProjectId, isImportError} from '@sanity/sdk'
|
|
3
|
+
import {isStudioConfig} from '@sanity/sdk/_internal'
|
|
3
4
|
import {useEffect, useMemo} from 'react'
|
|
4
5
|
import {ErrorBoundary, type FallbackProps} from 'react-error-boundary'
|
|
5
6
|
|
|
6
7
|
import {ComlinkTokenRefreshProvider} from '../../context/ComlinkTokenRefresh'
|
|
8
|
+
import {isWorkbenchEnvironment} from '../../context/workbenchToken'
|
|
9
|
+
import {WorkbenchTokenRefreshProvider} from '../../context/WorkbenchTokenRefresh'
|
|
7
10
|
import {useAuthState} from '../../hooks/auth/useAuthState'
|
|
8
11
|
import {useLoginUrl} from '../../hooks/auth/useLoginUrl'
|
|
9
12
|
import {useVerifyOrgProjects} from '../../hooks/auth/useVerifyOrgProjects'
|
|
10
13
|
import {useSanityInstance} from '../../hooks/context/useSanityInstance'
|
|
14
|
+
import {ChunkLoadError} from '../errors/ChunkLoadError'
|
|
11
15
|
import {CorsErrorComponent} from '../errors/CorsErrorComponent'
|
|
12
16
|
import {isInIframe} from '../utils'
|
|
13
17
|
import {AuthError} from './AuthError'
|
|
@@ -108,8 +112,24 @@ export function AuthBoundary({
|
|
|
108
112
|
LoginErrorComponent = LoginError,
|
|
109
113
|
...props
|
|
110
114
|
}: AuthBoundaryProps): React.ReactNode {
|
|
115
|
+
/**
|
|
116
|
+
* When the session is re-established (e.g. ComlinkTokenRefresh silently mints a
|
|
117
|
+
* fresh token via setAuthToken), the auth store returns to LOGGED_IN but the
|
|
118
|
+
* ErrorBoundary stays latched on its fallback until reset. Keying it on the
|
|
119
|
+
* recovered session lets it clear automatically
|
|
120
|
+
*/
|
|
121
|
+
const authState = useAuthState()
|
|
122
|
+
const sessionResetKey =
|
|
123
|
+
authState.type === AuthStateType.LOGGED_IN ? authState.token : authState.type
|
|
124
|
+
|
|
111
125
|
const FallbackComponent = useMemo(() => {
|
|
112
126
|
return function LoginComponentWithLayoutProps(fallbackProps: FallbackProps) {
|
|
127
|
+
// Chunk-load errors from any lazy-loaded code beneath this boundary
|
|
128
|
+
// (typically the consumer's app) get the chunk-aware fallback instead
|
|
129
|
+
// of being misreported as auth errors.
|
|
130
|
+
if (isImportError(fallbackProps.error)) {
|
|
131
|
+
return <ChunkLoadError {...fallbackProps} />
|
|
132
|
+
}
|
|
113
133
|
if (fallbackProps.error instanceof CorsOriginError) {
|
|
114
134
|
return (
|
|
115
135
|
<CorsErrorComponent
|
|
@@ -124,9 +144,11 @@ export function AuthBoundary({
|
|
|
124
144
|
|
|
125
145
|
return (
|
|
126
146
|
<ComlinkTokenRefreshProvider>
|
|
127
|
-
<
|
|
128
|
-
<
|
|
129
|
-
|
|
147
|
+
<WorkbenchTokenRefreshProvider>
|
|
148
|
+
<ErrorBoundary FallbackComponent={FallbackComponent} resetKeys={[sessionResetKey]}>
|
|
149
|
+
<AuthSwitch {...props} />
|
|
150
|
+
</ErrorBoundary>
|
|
151
|
+
</WorkbenchTokenRefreshProvider>
|
|
130
152
|
</ComlinkTokenRefreshProvider>
|
|
131
153
|
)
|
|
132
154
|
}
|
|
@@ -165,8 +187,9 @@ function AuthSwitch({
|
|
|
165
187
|
const loginUrl = useLoginUrl()
|
|
166
188
|
|
|
167
189
|
useEffect(() => {
|
|
168
|
-
if (isLoggedOut && !isInIframe() && !isStudio) {
|
|
169
|
-
// We don't want to redirect to login if we're in the Dashboard
|
|
190
|
+
if (isLoggedOut && !isInIframe() && !isStudio && !isWorkbenchEnvironment()) {
|
|
191
|
+
// We don't want to redirect to login if we're in the Dashboard, in studio
|
|
192
|
+
// mode, or in the workbench (the OS owns the session and mints the token)
|
|
170
193
|
window.location.href = loginUrl
|
|
171
194
|
}
|
|
172
195
|
}, [isLoggedOut, loginUrl, isStudio])
|
|
@@ -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(() =>
|
|
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:
|
|
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()
|
|
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,22 +1,20 @@
|
|
|
1
1
|
import {ClientError} from '@sanity/client'
|
|
2
|
-
import {
|
|
2
|
+
import {AuthStateType, getIsInDashboardState} from '@sanity/sdk'
|
|
3
3
|
import {
|
|
4
|
-
AuthStateType,
|
|
5
4
|
getClientErrorApiBody,
|
|
6
5
|
getClientErrorApiDescription,
|
|
7
|
-
isDatasetResource,
|
|
8
6
|
isProjectUserNotFoundClientError,
|
|
9
|
-
} from '@sanity/sdk'
|
|
10
|
-
import {
|
|
7
|
+
} from '@sanity/sdk/_internal'
|
|
8
|
+
import {Suspense, useCallback, useEffect, useMemo, useRef} from 'react'
|
|
11
9
|
import {type FallbackProps} from 'react-error-boundary'
|
|
12
10
|
|
|
13
|
-
import {ResourceContext} from '../../context/DefaultResourceContext'
|
|
14
11
|
import {useAuthState} from '../../hooks/auth/useAuthState'
|
|
15
12
|
import {useLogOut} from '../../hooks/auth/useLogOut'
|
|
16
|
-
import {
|
|
13
|
+
import {useSanityInstance} from '../../hooks/context/useSanityInstance'
|
|
17
14
|
import {Error} from '../errors/Error'
|
|
18
15
|
import {AuthError} from './AuthError'
|
|
19
16
|
import {ConfigurationError} from './ConfigurationError'
|
|
17
|
+
import {DashboardAccessRequest} from './DashboardAccessRequest'
|
|
20
18
|
/**
|
|
21
19
|
* @alpha
|
|
22
20
|
*/
|
|
@@ -40,74 +38,119 @@ export function LoginError({error, resetErrorBoundary}: LoginErrorProps): React.
|
|
|
40
38
|
|
|
41
39
|
const logout = useLogOut()
|
|
42
40
|
const authState = useAuthState()
|
|
43
|
-
const
|
|
44
|
-
const
|
|
41
|
+
const instance = useSanityInstance()
|
|
42
|
+
const {
|
|
43
|
+
config: {projectId},
|
|
44
|
+
} = instance
|
|
45
45
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
46
|
+
// Errors surfaced through `AuthBoundary` arrive wrapped in `AuthError`, with
|
|
47
|
+
// the original `ClientError` tucked under `.cause`. Unwrapping it here lets
|
|
48
|
+
// the 401/404 branches below respond to the real status code instead of
|
|
49
|
+
// silently skipping because `error instanceof ClientError` is false.
|
|
50
|
+
const clientError: ClientError | null =
|
|
51
|
+
error instanceof ClientError
|
|
52
|
+
? error
|
|
53
|
+
: error instanceof AuthError && error.cause instanceof ClientError
|
|
54
|
+
? error.cause
|
|
55
|
+
: null
|
|
56
|
+
|
|
57
|
+
const isInDashboard = getIsInDashboardState(instance).getCurrent()
|
|
50
58
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
59
|
+
const isProjectUserNotFound =
|
|
60
|
+
!!clientError && clientError.statusCode === 401 && isProjectUserNotFoundClientError(clientError)
|
|
61
|
+
|
|
62
|
+
// The dashboard access request flow relies on a comlink connection to the
|
|
63
|
+
// parent window. In standalone apps that connection never materializes, so
|
|
64
|
+
// we must skip it entirely to avoid suspending forever on the parent's
|
|
65
|
+
// Suspense boundary. Resolving to the projectId (or null) here lets the JSX
|
|
66
|
+
// render the child with a single non-null guard.
|
|
67
|
+
const dashboardAccessProjectId =
|
|
68
|
+
isProjectUserNotFound && projectId && isInDashboard ? projectId : null
|
|
58
69
|
|
|
59
70
|
const handleRetry = useCallback(async () => {
|
|
60
71
|
await logout()
|
|
61
72
|
resetErrorBoundary()
|
|
62
73
|
}, [logout, resetErrorBoundary])
|
|
63
74
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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.')
|
|
75
|
+
// Display state is fully derived from the inputs above, so we don't need
|
|
76
|
+
// to mirror it through useState/useEffect.
|
|
77
|
+
const {authErrorMessage, showRetryCta} = useMemo(() => {
|
|
78
|
+
let message = 'Please try again or contact support if the problem persists.'
|
|
79
|
+
let retry = true
|
|
80
|
+
|
|
81
|
+
if (clientError) {
|
|
82
|
+
if (clientError.statusCode === 401) {
|
|
83
|
+
if (isProjectUserNotFound) {
|
|
84
|
+
const description = getClientErrorApiDescription(clientError)
|
|
85
|
+
if (description) message = description
|
|
86
|
+
retry = false
|
|
87
|
+
} else if (!isInDashboard) {
|
|
88
|
+
message = 'Signing you out and returning to login...'
|
|
89
|
+
retry = true
|
|
89
90
|
}
|
|
90
|
-
|
|
91
|
+
// Dashboard non-projectUserNotFound 401: leave the current UI in place
|
|
92
|
+
// and let ComlinkTokenRefreshProvider request a fresh token from the
|
|
93
|
+
// parent window. The Retry button remains as a manual fallback.
|
|
94
|
+
} else if (clientError.statusCode === 404) {
|
|
95
|
+
const errorMessage = getClientErrorApiBody(clientError)?.message || ''
|
|
96
|
+
message =
|
|
97
|
+
errorMessage.startsWith('Session with sid') && errorMessage.endsWith('not found')
|
|
98
|
+
? 'The session ID is invalid or expired.'
|
|
99
|
+
: 'The login link is invalid or expired. Please try again.'
|
|
100
|
+
retry = true
|
|
91
101
|
}
|
|
92
102
|
}
|
|
93
103
|
if (authState.type !== AuthStateType.ERROR && error instanceof ConfigurationError) {
|
|
94
|
-
|
|
95
|
-
|
|
104
|
+
message = error.message
|
|
105
|
+
retry = true
|
|
96
106
|
}
|
|
97
|
-
|
|
107
|
+
return {authErrorMessage: message, showRetryCta: retry}
|
|
108
|
+
}, [authState, clientError, error, isInDashboard, isProjectUserNotFound])
|
|
109
|
+
|
|
110
|
+
// Guards against re-entering the standalone auto-logout branch below. Once
|
|
111
|
+
// `logout()` flips the auth store to LOGGED_OUT, `useAuthState` emits a new
|
|
112
|
+
// `authState` reference and re-runs this effect; without the ref we'd call
|
|
113
|
+
// `handleRetry` again on every emission and React eventually aborts with
|
|
114
|
+
// "Maximum update depth exceeded", leaving a blank page.
|
|
115
|
+
const hasAutoLoggedOutRef = useRef(false)
|
|
116
|
+
|
|
117
|
+
// Standalone apps: the token is bad and there's no parent window to mint a
|
|
118
|
+
// new one, so log the user out and let `AuthBoundary`'s LOGGED_OUT effect
|
|
119
|
+
// redirect to the Sanity login URL.
|
|
120
|
+
useEffect(() => {
|
|
121
|
+
if (
|
|
122
|
+
clientError &&
|
|
123
|
+
clientError.statusCode === 401 &&
|
|
124
|
+
!isProjectUserNotFound &&
|
|
125
|
+
!isInDashboard &&
|
|
126
|
+
!hasAutoLoggedOutRef.current
|
|
127
|
+
) {
|
|
128
|
+
hasAutoLoggedOutRef.current = true
|
|
129
|
+
handleRetry()
|
|
130
|
+
}
|
|
131
|
+
}, [clientError, handleRetry, isInDashboard, isProjectUserNotFound])
|
|
98
132
|
|
|
99
133
|
return (
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
134
|
+
<>
|
|
135
|
+
{dashboardAccessProjectId && (
|
|
136
|
+
<Suspense fallback={null}>
|
|
137
|
+
<DashboardAccessRequest projectId={dashboardAccessProjectId} />
|
|
138
|
+
</Suspense>
|
|
139
|
+
)}
|
|
140
|
+
<Error
|
|
141
|
+
heading={
|
|
142
|
+
error instanceof ConfigurationError ? 'Configuration Error' : 'Authentication Error'
|
|
143
|
+
}
|
|
144
|
+
description={authErrorMessage}
|
|
145
|
+
cta={
|
|
146
|
+
showRetryCta
|
|
147
|
+
? {
|
|
148
|
+
text: 'Retry',
|
|
149
|
+
onClick: handleRetry,
|
|
150
|
+
}
|
|
151
|
+
: undefined
|
|
152
|
+
}
|
|
153
|
+
/>
|
|
154
|
+
</>
|
|
112
155
|
)
|
|
113
156
|
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
|
|
2
|
+
|
|
3
|
+
import {render, screen} from '../../../test/test-utils'
|
|
4
|
+
import {ChunkLoadError} from './ChunkLoadError'
|
|
5
|
+
import {CHUNK_RELOAD_STORAGE_KEY} from './chunkReloadStorage'
|
|
6
|
+
|
|
7
|
+
const noop = (): void => {}
|
|
8
|
+
|
|
9
|
+
describe('ChunkLoadError', () => {
|
|
10
|
+
const reloadSpy = vi.fn()
|
|
11
|
+
const originalLocation = window.location
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
reloadSpy.mockReset()
|
|
15
|
+
window.sessionStorage.clear()
|
|
16
|
+
Object.defineProperty(window, 'location', {
|
|
17
|
+
configurable: true,
|
|
18
|
+
value: {...originalLocation, reload: reloadSpy},
|
|
19
|
+
})
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
Object.defineProperty(window, 'location', {configurable: true, value: originalLocation})
|
|
24
|
+
window.sessionStorage.clear()
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('triggers an automatic reload and renders nothing on the first occurrence', () => {
|
|
28
|
+
render(
|
|
29
|
+
<ChunkLoadError
|
|
30
|
+
error={new Error('Failed to fetch dynamically imported module')}
|
|
31
|
+
resetErrorBoundary={noop}
|
|
32
|
+
/>,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
expect(reloadSpy).toHaveBeenCalledTimes(1)
|
|
36
|
+
expect(window.sessionStorage.getItem(CHUNK_RELOAD_STORAGE_KEY)).toBe('1')
|
|
37
|
+
expect(screen.queryByText(/new version/i)).toBeNull()
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('renders the manual reload UI when the flag is already set', () => {
|
|
41
|
+
window.sessionStorage.setItem(CHUNK_RELOAD_STORAGE_KEY, '1')
|
|
42
|
+
|
|
43
|
+
render(
|
|
44
|
+
<ChunkLoadError
|
|
45
|
+
error={new Error('Failed to fetch dynamically imported module')}
|
|
46
|
+
resetErrorBoundary={noop}
|
|
47
|
+
/>,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
expect(reloadSpy).not.toHaveBeenCalled()
|
|
51
|
+
expect(screen.getByText('A new version is available')).toBeInTheDocument()
|
|
52
|
+
|
|
53
|
+
const button = screen.getByRole('button', {name: 'Reload page'})
|
|
54
|
+
button.click()
|
|
55
|
+
|
|
56
|
+
expect(reloadSpy).toHaveBeenCalledTimes(1)
|
|
57
|
+
expect(window.sessionStorage.getItem(CHUNK_RELOAD_STORAGE_KEY)).toBeNull()
|
|
58
|
+
})
|
|
59
|
+
})
|