@sanity/sdk-react 2.9.0 → 2.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +338 -215
- package/dist/index.js +564 -342
- package/dist/index.js.map +1 -1
- package/package.json +9 -14
- package/src/_exports/index.ts +2 -0
- package/src/_exports/sdk-react.ts +8 -0
- package/src/components/SDKProvider.test.tsx +5 -12
- package/src/components/SDKProvider.tsx +58 -28
- package/src/components/SanityApp.tsx +2 -2
- package/src/components/auth/AuthBoundary.tsx +8 -1
- package/src/components/auth/DashboardAccessRequest.tsx +37 -0
- package/src/components/auth/LoginError.test.tsx +191 -5
- package/src/components/auth/LoginError.tsx +100 -56
- package/src/components/errors/ChunkLoadError.test.tsx +59 -0
- package/src/components/errors/ChunkLoadError.tsx +56 -0
- package/src/components/errors/chunkReloadStorage.ts +57 -0
- package/src/config/handles.ts +55 -0
- package/src/constants.ts +5 -0
- package/src/context/DefaultResourceContext.ts +10 -0
- package/src/context/PerspectiveContext.ts +12 -0
- package/src/context/ResourceProvider.test.tsx +2 -2
- package/src/context/ResourceProvider.tsx +56 -51
- package/src/context/ResourcesContext.tsx +7 -0
- package/src/context/SanityInstanceProvider.test.tsx +100 -0
- package/src/context/SanityInstanceProvider.tsx +71 -0
- package/src/hooks/agent/agentActions.ts +55 -38
- package/src/hooks/auth/useVerifyOrgProjects.tsx +13 -6
- package/src/hooks/context/useResource.test.tsx +32 -0
- package/src/hooks/context/useResource.ts +24 -0
- package/src/hooks/context/useSanityInstance.test.tsx +42 -111
- package/src/hooks/context/useSanityInstance.ts +28 -50
- package/src/hooks/dashboard/useDispatchIntent.test.ts +11 -7
- package/src/hooks/dashboard/useDispatchIntent.ts +7 -7
- package/src/hooks/dashboard/useManageFavorite.test.tsx +16 -12
- package/src/hooks/dashboard/utils/useResourceIdFromDocumentHandle.test.ts +15 -15
- package/src/hooks/dashboard/utils/useResourceIdFromDocumentHandle.ts +13 -17
- package/src/hooks/document/{useApplyDocumentActions.test.ts → useApplyDocumentActions.test.tsx} +46 -81
- package/src/hooks/document/useApplyDocumentActions.ts +33 -67
- package/src/hooks/document/useDocument.ts +4 -6
- package/src/hooks/document/useDocumentEvent.ts +8 -7
- package/src/hooks/document/useDocumentPermissions.test.tsx +60 -152
- package/src/hooks/document/useDocumentPermissions.ts +78 -55
- package/src/hooks/document/useDocumentSyncStatus.ts +2 -2
- package/src/hooks/document/useEditDocument.test.tsx +25 -60
- package/src/hooks/document/useEditDocument.ts +3 -3
- package/src/hooks/documents/useDocuments.ts +19 -11
- package/src/hooks/helpers/createStateSourceHook.tsx +1 -2
- package/src/hooks/helpers/useNormalizedResourceOptions.test.tsx +253 -0
- package/src/hooks/helpers/useNormalizedResourceOptions.ts +169 -0
- package/src/hooks/helpers/useTrackHookUsage.ts +2 -2
- package/src/hooks/organizations/useOrganization.test-d.ts +53 -0
- package/src/hooks/organizations/useOrganization.test.ts +65 -0
- package/src/hooks/organizations/useOrganization.ts +40 -0
- package/src/hooks/organizations/useOrganizations.test-d.ts +55 -0
- package/src/hooks/organizations/useOrganizations.test.ts +85 -0
- package/src/hooks/organizations/useOrganizations.ts +45 -0
- package/src/hooks/paginatedDocuments/usePaginatedDocuments.ts +29 -14
- package/src/hooks/presence/usePresence.test.tsx +56 -9
- package/src/hooks/presence/usePresence.ts +16 -4
- package/src/hooks/preview/useDocumentPreview.tsx +8 -10
- package/src/hooks/projection/useDocumentProjection.ts +7 -9
- package/src/hooks/projects/useProject.test-d.ts +49 -0
- package/src/hooks/projects/useProject.ts +33 -41
- package/src/hooks/projects/useProjects.test-d.ts +49 -0
- package/src/hooks/projects/useProjects.ts +17 -23
- package/src/hooks/query/useQuery.ts +11 -10
- package/src/hooks/releases/useActiveReleases.ts +14 -14
- package/src/hooks/releases/usePerspective.ts +11 -16
- package/src/hooks/users/useUser.ts +1 -1
- package/src/hooks/users/useUsers.ts +1 -1
- package/src/context/SourcesContext.tsx +0 -7
- package/src/hooks/helpers/useNormalizedSourceOptions.ts +0 -107
|
@@ -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,
|
|
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
|
-
} =
|
|
45
|
+
} = instance
|
|
45
46
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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.')
|
|
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
|
-
|
|
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
|
-
|
|
95
|
-
|
|
105
|
+
message = error.message
|
|
106
|
+
retry = true
|
|
96
107
|
}
|
|
97
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
}
|
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import {useEffect} from 'react'
|
|
2
|
+
import {type FallbackProps} from 'react-error-boundary'
|
|
3
|
+
|
|
4
|
+
import {clearChunkReloadFlag, readChunkReloadFlag, setChunkReloadFlag} from './chunkReloadStorage'
|
|
5
|
+
import {Error} from './Error'
|
|
6
|
+
|
|
7
|
+
function reload(): void {
|
|
8
|
+
try {
|
|
9
|
+
window.location.reload()
|
|
10
|
+
} catch {
|
|
11
|
+
// No-op: nothing useful we can do if reload itself throws.
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Default fallback rendered when a dynamic-import or chunk-load error
|
|
17
|
+
* bubbles up to the SDK's top-level error boundary.
|
|
18
|
+
*
|
|
19
|
+
* On the first occurrence in a session we set a flag and trigger
|
|
20
|
+
* window.location.reload(), since chunk-load errors almost always indicate a
|
|
21
|
+
* stale tab that simply needs a fresh index.html. If the flag is already set
|
|
22
|
+
* we render a manual reload UI instead, which prevents an infinite reload
|
|
23
|
+
* loop in the rare case the error is genuinely unrecoverable (network
|
|
24
|
+
* outage, CSP, etc.).
|
|
25
|
+
*
|
|
26
|
+
* @internal
|
|
27
|
+
*/
|
|
28
|
+
export function ChunkLoadError(_props: FallbackProps): React.ReactNode {
|
|
29
|
+
const alreadyAttempted = readChunkReloadFlag()
|
|
30
|
+
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
if (alreadyAttempted) return
|
|
33
|
+
setChunkReloadFlag()
|
|
34
|
+
reload()
|
|
35
|
+
}, [alreadyAttempted])
|
|
36
|
+
|
|
37
|
+
if (!alreadyAttempted) {
|
|
38
|
+
// Render nothing during the brief window before the page reloads so the
|
|
39
|
+
// user does not see a flash of error UI.
|
|
40
|
+
return null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<Error
|
|
45
|
+
heading="A new version is available"
|
|
46
|
+
description="The page tried to load an asset that no longer exists. Reload to continue with the latest version."
|
|
47
|
+
cta={{
|
|
48
|
+
text: 'Reload page',
|
|
49
|
+
onClick: () => {
|
|
50
|
+
clearChunkReloadFlag()
|
|
51
|
+
reload()
|
|
52
|
+
},
|
|
53
|
+
}}
|
|
54
|
+
/>
|
|
55
|
+
)
|
|
56
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session-storage key tracking whether the SDK has already attempted an
|
|
3
|
+
* automatic reload in response to a chunk-load error during this session.
|
|
4
|
+
*
|
|
5
|
+
* @internal
|
|
6
|
+
*/
|
|
7
|
+
export const CHUNK_RELOAD_STORAGE_KEY = '__sanity_sdk_chunk_reload_attempted'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Returns true when this session has already triggered an automatic reload.
|
|
11
|
+
* Returns false if session storage is unreadable.
|
|
12
|
+
*
|
|
13
|
+
* @internal
|
|
14
|
+
*/
|
|
15
|
+
export function readChunkReloadFlag(): boolean {
|
|
16
|
+
try {
|
|
17
|
+
if (typeof window === 'undefined' || typeof window.sessionStorage === 'undefined') {
|
|
18
|
+
return false
|
|
19
|
+
}
|
|
20
|
+
return window.sessionStorage.getItem(CHUNK_RELOAD_STORAGE_KEY) !== null
|
|
21
|
+
} catch {
|
|
22
|
+
return false
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Marks the session as having attempted an automatic reload, so the next
|
|
28
|
+
* chunk-load error renders the manual reload UI instead of looping.
|
|
29
|
+
*
|
|
30
|
+
* @internal
|
|
31
|
+
*/
|
|
32
|
+
export function setChunkReloadFlag(): void {
|
|
33
|
+
try {
|
|
34
|
+
if (typeof window === 'undefined' || typeof window.sessionStorage === 'undefined') return
|
|
35
|
+
window.sessionStorage.setItem(CHUNK_RELOAD_STORAGE_KEY, '1')
|
|
36
|
+
} catch {
|
|
37
|
+
// Storage may be unavailable (private mode quotas, disabled cookies).
|
|
38
|
+
// Falling through means the user sees the manual-reload UI instead of an
|
|
39
|
+
// automatic reload, which is the correct degradation.
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Clears the chunk-reload flag. Called from SDKProvider once the SDK
|
|
45
|
+
* mounts successfully past the error boundary so a future incident in the
|
|
46
|
+
* same session can trigger another automatic reload.
|
|
47
|
+
*
|
|
48
|
+
* @internal
|
|
49
|
+
*/
|
|
50
|
+
export function clearChunkReloadFlag(): void {
|
|
51
|
+
try {
|
|
52
|
+
if (typeof window === 'undefined' || typeof window.sessionStorage === 'undefined') return
|
|
53
|
+
window.sessionStorage.removeItem(CHUNK_RELOAD_STORAGE_KEY)
|
|
54
|
+
} catch {
|
|
55
|
+
// No-op: see setChunkReloadFlag.
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type DatasetHandle,
|
|
3
|
+
type DocumentHandle as CoreDocumentHandle,
|
|
4
|
+
type DocumentTypeHandle as CoreDocumentTypeHandle,
|
|
5
|
+
} from '@sanity/sdk'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* React SDK resource handle — extends the core DatasetHandle with `resourceName`
|
|
9
|
+
* for context-based resource resolution.
|
|
10
|
+
*
|
|
11
|
+
* Use this (or its subtypes) as the options type for custom hooks that need to
|
|
12
|
+
* accept a resource. It accepts a `resource` object, a `resourceName` registered
|
|
13
|
+
* via the `resources` prop on `<SanityApp>`, or a bare `projectId`/`dataset` pair
|
|
14
|
+
* for backward compatibility.
|
|
15
|
+
*
|
|
16
|
+
* @public
|
|
17
|
+
*/
|
|
18
|
+
export interface ResourceHandle<
|
|
19
|
+
TDataset extends string = string,
|
|
20
|
+
TProjectId extends string = string,
|
|
21
|
+
> extends DatasetHandle<TDataset, TProjectId> {
|
|
22
|
+
/**
|
|
23
|
+
* Name of a resource registered via the `resources` prop on `<SanityApp>`.
|
|
24
|
+
* Resolved to a `DocumentResource` at the React layer.
|
|
25
|
+
*/
|
|
26
|
+
resourceName?: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* React SDK document-type handle. Adds `resourceName` to the core `DocumentTypeHandle`.
|
|
31
|
+
* @public
|
|
32
|
+
*/
|
|
33
|
+
export interface DocumentTypeHandle<
|
|
34
|
+
TDocumentType extends string = string,
|
|
35
|
+
TDataset extends string = string,
|
|
36
|
+
TProjectId extends string = string,
|
|
37
|
+
> extends CoreDocumentTypeHandle<TDocumentType, TDataset, TProjectId> {
|
|
38
|
+
resourceName?: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* React SDK document handle. Adds `resourceName` to the core `DocumentHandle`.
|
|
43
|
+
*
|
|
44
|
+
* Import from `@sanity/sdk-react` (not `@sanity/sdk`) when writing option types
|
|
45
|
+
* for hooks — this version understands `resourceName` resolution.
|
|
46
|
+
*
|
|
47
|
+
* @public
|
|
48
|
+
*/
|
|
49
|
+
export interface DocumentHandle<
|
|
50
|
+
TDocumentType extends string = string,
|
|
51
|
+
TDataset extends string = string,
|
|
52
|
+
TProjectId extends string = string,
|
|
53
|
+
> extends CoreDocumentHandle<TDocumentType, TDataset, TProjectId> {
|
|
54
|
+
resourceName?: string
|
|
55
|
+
}
|
package/src/constants.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import {type DocumentResource} from '@sanity/sdk'
|
|
2
|
+
import {createContext} from 'react'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Provides the active DocumentResource for a subtree.
|
|
6
|
+
* Set by ResourceProvider; read by useNormalizedResourceOptions as a fallback
|
|
7
|
+
* when hooks receive no explicit resource or resourceName.
|
|
8
|
+
* @internal
|
|
9
|
+
*/
|
|
10
|
+
export const ResourceContext = createContext<DocumentResource | undefined>(undefined)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import {type PerspectiveHandle} from '@sanity/sdk'
|
|
2
|
+
import {createContext} from 'react'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Provides the active perspective for a subtree.
|
|
6
|
+
* Set by ResourceProvider; injected by useNormalizedResourceOptions when
|
|
7
|
+
* the hook's options don't include an explicit perspective.
|
|
8
|
+
* @internal
|
|
9
|
+
*/
|
|
10
|
+
export const PerspectiveContext = createContext<PerspectiveHandle['perspective'] | undefined>(
|
|
11
|
+
undefined,
|
|
12
|
+
)
|
|
@@ -78,7 +78,7 @@ describe('ResourceProvider', () => {
|
|
|
78
78
|
})
|
|
79
79
|
})
|
|
80
80
|
|
|
81
|
-
it('
|
|
81
|
+
it('reuses instance when parent context exists', async () => {
|
|
82
82
|
const parentConfig: SanityConfig = {...testConfig, dataset: 'parent-dataset'}
|
|
83
83
|
const child = promiseWithResolvers<SanityInstance | null>()
|
|
84
84
|
|
|
@@ -97,7 +97,7 @@ describe('ResourceProvider', () => {
|
|
|
97
97
|
)
|
|
98
98
|
|
|
99
99
|
const childInstance = await child.promise
|
|
100
|
-
expect(childInstance?.config).toEqual(
|
|
100
|
+
expect(childInstance?.config).toEqual(parentConfig)
|
|
101
101
|
expect(childInstance?.isDisposed()).toBe(false)
|
|
102
102
|
})
|
|
103
103
|
|
|
@@ -1,8 +1,18 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
createSanityInstance,
|
|
3
|
+
type DatasetResource,
|
|
4
|
+
type DocumentResource,
|
|
5
|
+
isDatasetResource,
|
|
6
|
+
type SanityConfig,
|
|
7
|
+
type SanityInstance,
|
|
8
|
+
} from '@sanity/sdk'
|
|
2
9
|
import {initTelemetry} from '@sanity/sdk/_internal'
|
|
3
|
-
import {
|
|
10
|
+
import {useContext, useEffect, useMemo, useRef, useState} from 'react'
|
|
4
11
|
|
|
12
|
+
import {ResourceContext} from './DefaultResourceContext'
|
|
13
|
+
import {PerspectiveContext} from './PerspectiveContext'
|
|
5
14
|
import {SanityInstanceContext} from './SanityInstanceContext'
|
|
15
|
+
import {SanityInstanceProvider} from './SanityInstanceProvider'
|
|
6
16
|
|
|
7
17
|
const DEFAULT_FALLBACK = (
|
|
8
18
|
<>
|
|
@@ -16,73 +26,63 @@ const DEFAULT_FALLBACK = (
|
|
|
16
26
|
*/
|
|
17
27
|
export interface ResourceProviderProps extends SanityConfig {
|
|
18
28
|
/**
|
|
19
|
-
*
|
|
20
|
-
*
|
|
29
|
+
* The document resource (project/dataset, media library, or canvas)
|
|
30
|
+
* for this subtree. Hooks that don't specify an explicit resource will
|
|
31
|
+
* use this value.
|
|
32
|
+
*/
|
|
33
|
+
resource?: DocumentResource
|
|
34
|
+
/**
|
|
35
|
+
* React node to show while content is loading.
|
|
36
|
+
* Used as the fallback for the internal Suspense boundary.
|
|
21
37
|
*/
|
|
22
38
|
fallback: React.ReactNode
|
|
23
39
|
children: React.ReactNode
|
|
24
40
|
}
|
|
25
41
|
|
|
26
42
|
/**
|
|
27
|
-
* Provides
|
|
43
|
+
* Provides Sanity configuration to child components through React Context.
|
|
28
44
|
*
|
|
29
45
|
* @internal
|
|
30
46
|
*
|
|
31
|
-
* @
|
|
32
|
-
* The ResourceProvider creates a hierarchical structure of Sanity instances:
|
|
33
|
-
* - When used as a root provider, it creates a new Sanity instance with the given config
|
|
34
|
-
* - When nested inside another ResourceProvider, it creates a child instance that
|
|
35
|
-
* inherits and extends the parent's configuration
|
|
36
|
-
*
|
|
37
|
-
* Features:
|
|
38
|
-
* - Automatically manages the lifecycle of Sanity instances
|
|
39
|
-
* - Disposes instances when the component unmounts
|
|
40
|
-
* - Includes a Suspense boundary for data loading
|
|
41
|
-
* - Enables hierarchical configuration inheritance
|
|
42
|
-
*
|
|
43
|
-
* Use this component to:
|
|
44
|
-
* - Set up project/dataset configuration for an application
|
|
45
|
-
* - Override specific configuration values in a section of your app
|
|
46
|
-
* - Create isolated instance hierarchies for different features
|
|
47
|
-
*
|
|
48
|
-
* @example Creating a root provider
|
|
47
|
+
* @example
|
|
49
48
|
* ```tsx
|
|
50
49
|
* <ResourceProvider
|
|
51
|
-
* projectId
|
|
52
|
-
* dataset="production"
|
|
50
|
+
* resource={{ projectId: 'your-project-id', dataset: 'production' }}
|
|
53
51
|
* fallback={<LoadingSpinner />}
|
|
54
52
|
* >
|
|
55
53
|
* <YourApp />
|
|
56
54
|
* </ResourceProvider>
|
|
57
55
|
* ```
|
|
58
|
-
*
|
|
59
|
-
* @example Creating nested providers with configuration inheritance
|
|
60
|
-
* ```tsx
|
|
61
|
-
* // Root provider with production config with nested provider for preview features with custom dataset
|
|
62
|
-
* <ResourceProvider projectId="abc123" dataset="production" fallback={<Loading />}>
|
|
63
|
-
* <div>...Main app content</div>
|
|
64
|
-
* <Dashboard />
|
|
65
|
-
* <ResourceProvider dataset="preview" fallback={<Loading />}>
|
|
66
|
-
* <PreviewFeatures />
|
|
67
|
-
* </ResourceProvider>
|
|
68
|
-
* </ResourceProvider>
|
|
69
|
-
* ```
|
|
70
56
|
*/
|
|
71
57
|
export function ResourceProvider({
|
|
72
58
|
children,
|
|
73
59
|
fallback,
|
|
60
|
+
resource,
|
|
74
61
|
...config
|
|
75
62
|
}: ResourceProviderProps): React.ReactNode {
|
|
76
|
-
const
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
63
|
+
const parentPerspective = useContext(PerspectiveContext)
|
|
64
|
+
const parentResource = useContext(ResourceContext)
|
|
65
|
+
const parentInstance = useContext(SanityInstanceContext)
|
|
66
|
+
|
|
67
|
+
const {projectId, dataset, perspective} = config
|
|
68
|
+
|
|
69
|
+
const [instance] = useState<SanityInstance>(() => parentInstance ?? createSanityInstance(config))
|
|
81
70
|
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
71
|
+
const configResource: DatasetResource | undefined = useMemo(() => {
|
|
72
|
+
if (projectId && dataset) {
|
|
73
|
+
return {projectId, dataset}
|
|
74
|
+
}
|
|
75
|
+
return undefined
|
|
76
|
+
}, [projectId, dataset])
|
|
77
|
+
|
|
78
|
+
const effectiveResource = useMemo(() => {
|
|
79
|
+
return resource ?? configResource ?? parentResource
|
|
80
|
+
}, [resource, configResource, parentResource])
|
|
81
|
+
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
if (effectiveResource && isDatasetResource(effectiveResource))
|
|
84
|
+
initTelemetry(instance, effectiveResource.projectId)
|
|
85
|
+
}, [instance, effectiveResource])
|
|
86
86
|
|
|
87
87
|
// Ref to hold the scheduled disposal timer.
|
|
88
88
|
const disposal = useRef<{
|
|
@@ -101,17 +101,22 @@ export function ResourceProvider({
|
|
|
101
101
|
disposal.current = {
|
|
102
102
|
instance,
|
|
103
103
|
timeoutId: setTimeout(() => {
|
|
104
|
-
|
|
104
|
+
// don't dispose the parent instance when this unmounts
|
|
105
|
+
if (!instance.isDisposed() && instance !== parentInstance) {
|
|
105
106
|
instance.dispose()
|
|
106
107
|
}
|
|
107
108
|
}, 0),
|
|
108
109
|
}
|
|
109
110
|
}
|
|
110
|
-
}, [instance])
|
|
111
|
+
}, [instance, parentInstance])
|
|
111
112
|
|
|
112
113
|
return (
|
|
113
|
-
<
|
|
114
|
-
<
|
|
115
|
-
|
|
114
|
+
<SanityInstanceProvider instance={instance} fallback={fallback ?? DEFAULT_FALLBACK}>
|
|
115
|
+
<ResourceContext.Provider value={effectiveResource}>
|
|
116
|
+
<PerspectiveContext.Provider value={perspective ?? parentPerspective}>
|
|
117
|
+
{children}
|
|
118
|
+
</PerspectiveContext.Provider>
|
|
119
|
+
</ResourceContext.Provider>
|
|
120
|
+
</SanityInstanceProvider>
|
|
116
121
|
)
|
|
117
122
|
}
|