@sanity/sdk-react 3.1.0 → 3.2.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/_exports/dashboard-internal.d.ts +2 -0
- package/dist/_exports/dashboard-internal.js +2 -0
- package/dist/_exports/dashboard.d.ts +457 -7
- package/dist/_exports/dashboard.d.ts.map +1 -1
- package/dist/_exports/dashboard.js +623 -2
- package/dist/_exports/dashboard.js.map +1 -1
- package/dist/index.d.ts +90 -87
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -5
- package/dist/index.js.map +1 -1
- package/dist/{useStudioWorkspacesByProjectIdDataset-DxUlukmF.js → useStudioWorkspacesByProjectIdDataset-BZ_Ud887.js} +53 -56
- package/dist/useStudioWorkspacesByProjectIdDataset-BZ_Ud887.js.map +1 -0
- package/package.json +34 -32
- package/src/_exports/dashboard-internal.ts +1 -0
- package/src/_exports/dashboard.test-d.ts +64 -0
- package/src/_exports/dashboard.ts +70 -0
- package/src/_exports/index.ts +1 -1
- package/src/components/auth/AuthBoundary.test.tsx +34 -0
- package/src/components/auth/AuthBoundary.tsx +1 -2
- package/src/context/DashboardTokenRefresh.test.tsx +124 -22
- package/src/context/DashboardTokenRefresh.tsx +31 -15
- package/src/dashboard/createRemoteInstance.test.ts +168 -0
- package/src/dashboard/createRemoteInstance.ts +123 -0
- package/src/dashboard/module.ts +39 -0
- package/src/dashboard/remoteClientState.ts +27 -0
- package/src/dashboard/urlFor.test.ts +246 -0
- package/src/dashboard/urlFor.ts +462 -0
- package/src/hooks/applications/useApplication.test-d.ts +1 -1
- package/src/hooks/dashboard/useApplication.test.tsx +59 -0
- package/src/hooks/dashboard/useApplication.ts +22 -0
- package/src/hooks/dashboard/useApplicationConfig.test.tsx +106 -0
- package/src/hooks/dashboard/useApplicationConfig.ts +45 -0
- package/src/hooks/dashboard/useApplicationConfigs.test.tsx +89 -0
- package/src/hooks/dashboard/useApplicationConfigs.ts +26 -0
- package/src/hooks/dashboard/useApplicationForegroundId.test.tsx +54 -0
- package/src/hooks/dashboard/useApplicationForegroundId.ts +22 -0
- package/src/hooks/dashboard/useApplications.test.tsx +249 -0
- package/src/hooks/dashboard/useApplications.ts +128 -0
- package/src/hooks/dashboard/useEmit.test.tsx +124 -0
- package/src/hooks/dashboard/useEmit.ts +78 -0
- package/src/hooks/dashboard/useNavigate.ts +1 -3
- package/src/hooks/dashboard/useRemoteClient.test.tsx +88 -0
- package/src/hooks/dashboard/useRemoteClient.ts +10 -0
- package/src/hooks/dashboard/useTopic.test.tsx +200 -0
- package/src/hooks/dashboard/useTopic.ts +29 -0
- package/src/hooks/dashboard/useUpdateFavorite.test.tsx +8 -1
- package/src/hooks/document/useApplyDocumentActions.ts +3 -4
- package/src/hooks/document/useCreateDocument.ts +2 -3
- package/src/hooks/document/useDocument.ts +11 -6
- package/src/hooks/document/useEditDocument.ts +5 -5
- package/src/hooks/projection/useDocumentProjection.ts +6 -7
- package/src/hooks/query/useQuery.ts +3 -4
- package/dist/useStudioWorkspacesByProjectIdDataset-DxUlukmF.js.map +0 -1
- package/src/context/dashboardToken.ts +0 -63
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import {type ApplicationBase} from '@sanity/sdk'
|
|
2
|
+
import {type RemoteModuleRef, type ValueOf} from '@sanity/sdk/dashboard'
|
|
3
|
+
import {useMemo} from 'react'
|
|
4
|
+
|
|
5
|
+
import {useTopic} from './useTopic'
|
|
6
|
+
|
|
7
|
+
type DashboardTopicApplication = Extract<
|
|
8
|
+
NonNullable<ValueOf<'applications.list'>>,
|
|
9
|
+
{ok: true}
|
|
10
|
+
>['value'][number]
|
|
11
|
+
type DashboardApplicationInterface = NonNullable<
|
|
12
|
+
NonNullable<DashboardTopicApplication['activeDeployment']>['interfaces']
|
|
13
|
+
>[number]
|
|
14
|
+
type ViewInterface = Exclude<DashboardApplicationInterface, {type: 'worker'}>
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A dashboard view exposed by an application.
|
|
18
|
+
* @public
|
|
19
|
+
*/
|
|
20
|
+
export type DashboardView = {
|
|
21
|
+
[Type in ViewInterface['type']]: Omit<Extract<ViewInterface, {type: Type}>, 'type'> & {
|
|
22
|
+
readonly application: ApplicationBase
|
|
23
|
+
readonly module: RemoteModuleRef
|
|
24
|
+
readonly surface: Type extends 'app' ? 'window' : Type
|
|
25
|
+
}
|
|
26
|
+
}[ViewInterface['type']]
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A web worker exposed by an application.
|
|
30
|
+
* @public
|
|
31
|
+
*/
|
|
32
|
+
export type DashboardWebWorker = Extract<DashboardApplicationInterface, {type: 'worker'}> & {
|
|
33
|
+
readonly application: ApplicationBase
|
|
34
|
+
readonly module: RemoteModuleRef
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The minimal Brett application fields with its loadable views and web workers.
|
|
39
|
+
* @public
|
|
40
|
+
*/
|
|
41
|
+
export type DashboardApplication = ApplicationBase & {
|
|
42
|
+
readonly views: DashboardView[]
|
|
43
|
+
readonly webWorkers: DashboardWebWorker[]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
type SanityGlobal = typeof globalThis & {__SANITY_STAGING__?: boolean}
|
|
47
|
+
|
|
48
|
+
// `null` when neither address exists: the caller exposes no modules for that application
|
|
49
|
+
// rather than failing the whole list on one bad record.
|
|
50
|
+
const applicationOrigin = (application: ApplicationBase): string | null => {
|
|
51
|
+
if (application.externalUrl !== null) return new URL(application.externalUrl).origin
|
|
52
|
+
if (application.slug === null) return null
|
|
53
|
+
|
|
54
|
+
// Read at runtime, not via a bundler define: a remote is built once and runs in whichever
|
|
55
|
+
// host page loaded it, and the host sets this flag. Mirrors workbench's `getSanityEnv`.
|
|
56
|
+
const staging = (globalThis as SanityGlobal).__SANITY_STAGING__ === true
|
|
57
|
+
if (application.isSingleton) {
|
|
58
|
+
const domain = staging ? 'run.sanity.work' : 'sanity.run'
|
|
59
|
+
return `https://${application.slug}-apps-${application.organizationId}.${domain}`
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const domain = staging ? 'studio.sanity.work' : 'sanity.studio'
|
|
63
|
+
return `https://${application.slug}.${domain}`
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Only a federated deployment (one with a module federation manifest) exposes loadable modules.
|
|
67
|
+
const loadableInterfaces = ({
|
|
68
|
+
activeDeployment,
|
|
69
|
+
config,
|
|
70
|
+
}: DashboardTopicApplication): readonly DashboardApplicationInterface[] =>
|
|
71
|
+
config?.mfManifest === undefined ? [] : (activeDeployment?.interfaces ?? [])
|
|
72
|
+
|
|
73
|
+
const toApplication = (application: DashboardTopicApplication): DashboardApplication => {
|
|
74
|
+
const {activeDeployment: _activeDeployment, config: _config, ...applicationBase} = application
|
|
75
|
+
const interfaces = loadableInterfaces(application)
|
|
76
|
+
// Nothing to load without interfaces or an origin to load them from.
|
|
77
|
+
const entry = interfaces.length === 0 ? null : applicationOrigin(applicationBase)
|
|
78
|
+
if (entry === null) return {...applicationBase, views: [], webWorkers: []}
|
|
79
|
+
|
|
80
|
+
const views: DashboardView[] = []
|
|
81
|
+
const webWorkers: DashboardWebWorker[] = []
|
|
82
|
+
|
|
83
|
+
for (const extension of interfaces) {
|
|
84
|
+
const module: RemoteModuleRef = {
|
|
85
|
+
entry,
|
|
86
|
+
moduleId: `${applicationBase.id}/${extension.moduleId}`,
|
|
87
|
+
version: extension.version,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (extension.type === 'worker') {
|
|
91
|
+
webWorkers.push({...extension, application: applicationBase, module})
|
|
92
|
+
continue
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const {type, ...view} = extension
|
|
96
|
+
// TS cannot correlate `surface` with the narrowed `type` across the mapped union; the
|
|
97
|
+
// cast is checked by the `DashboardView` mapping above and the surface assertions in the tests.
|
|
98
|
+
views.push({
|
|
99
|
+
...view,
|
|
100
|
+
application: applicationBase,
|
|
101
|
+
module,
|
|
102
|
+
surface: type === 'app' ? 'window' : type,
|
|
103
|
+
} as DashboardView)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return {...applicationBase, views, webWorkers}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Returns the applications available in the dashboard.
|
|
111
|
+
*
|
|
112
|
+
* Suspends until the dashboard publishes its application list; a cleared list is empty. Throws a
|
|
113
|
+
* `TopicError` to the nearest error boundary when the dashboard fails to load applications.
|
|
114
|
+
*
|
|
115
|
+
* @example
|
|
116
|
+
* ```tsx
|
|
117
|
+
* function Applications() {
|
|
118
|
+
* const applications = useApplications()
|
|
119
|
+
* return applications.map((application) => <div key={application.id}>{application.title}</div>)
|
|
120
|
+
* }
|
|
121
|
+
* ```
|
|
122
|
+
*
|
|
123
|
+
* @public
|
|
124
|
+
*/
|
|
125
|
+
export function useApplications(): DashboardApplication[] {
|
|
126
|
+
const applications = useTopic('applications.list')
|
|
127
|
+
return useMemo(() => applications?.map(toApplication) ?? [], [applications])
|
|
128
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import {installMessageBus, resetMessageBus} from '@sanity/sdk/_internal'
|
|
2
|
+
import {
|
|
3
|
+
type MessageBusEmitOptions,
|
|
4
|
+
type MessageBusEmitResult,
|
|
5
|
+
type MessageBusHost,
|
|
6
|
+
type PayloadOf,
|
|
7
|
+
type ReplyOf,
|
|
8
|
+
} from '@sanity/sdk/dashboard'
|
|
9
|
+
import {Suspense, use, useState} from 'react'
|
|
10
|
+
import {afterEach, beforeEach, describe, expect, expectTypeOf, it, vi} from 'vitest'
|
|
11
|
+
|
|
12
|
+
import {act, render, renderHook, screen} from '../../../test/test-utils'
|
|
13
|
+
import {useEmit} from './useEmit'
|
|
14
|
+
|
|
15
|
+
const MESSAGE_BUS_KEY = Symbol.for('sanity.os.bus')
|
|
16
|
+
|
|
17
|
+
let host: MessageBusHost
|
|
18
|
+
|
|
19
|
+
describe('useEmit', () => {
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
// The SDK resolves its own app ID from the CLI-embedded global.
|
|
22
|
+
vi.stubGlobal('__SANITY_APP_ID__', 'app')
|
|
23
|
+
host = installMessageBus({appId: 'dashboard'})
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
resetMessageBus()
|
|
28
|
+
delete (globalThis as {[MESSAGE_BUS_KEY]?: unknown})[MESSAGE_BUS_KEY]
|
|
29
|
+
vi.unstubAllGlobals()
|
|
30
|
+
vi.restoreAllMocks()
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('returns a stable, typed emitter and resolves with the topic reply', async () => {
|
|
34
|
+
host.subscribe('auth.token.refresh', (message) => message.reply('token'))
|
|
35
|
+
const {result, rerender} = renderHook(() => useEmit('auth.token.refresh'))
|
|
36
|
+
const emit = result.current
|
|
37
|
+
|
|
38
|
+
expectTypeOf(emit).toEqualTypeOf<
|
|
39
|
+
(
|
|
40
|
+
payload?: void,
|
|
41
|
+
options?: MessageBusEmitOptions,
|
|
42
|
+
) => MessageBusEmitResult<ReplyOf<'auth.token.refresh'>>
|
|
43
|
+
>()
|
|
44
|
+
|
|
45
|
+
await expect(emit()).resolves.toBe('token')
|
|
46
|
+
|
|
47
|
+
rerender()
|
|
48
|
+
expect(result.current).toBe(emit)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('delivers fire-and-forget payloads', () => {
|
|
52
|
+
const payloads: PayloadOf<'panels.mode.set'>[] = []
|
|
53
|
+
host.subscribe('panels.mode.set', (message) => payloads.push(message.payload))
|
|
54
|
+
const {result} = renderHook(() => useEmit('panels.mode.set'))
|
|
55
|
+
const payload = {name: 'comments', mode: 'aside'} as const
|
|
56
|
+
|
|
57
|
+
result.current(payload)
|
|
58
|
+
|
|
59
|
+
expect(payloads).toEqual([payload])
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('forwards emit options to the bus', async () => {
|
|
63
|
+
host.subscribe('auth.token.refresh', () => {
|
|
64
|
+
// never replies; the caller's signal must be what ends the wait
|
|
65
|
+
})
|
|
66
|
+
const {result} = renderHook(() => useEmit('auth.token.refresh'))
|
|
67
|
+
|
|
68
|
+
await expect(result.current(undefined, {signal: AbortSignal.abort()})).rejects.toMatchObject({
|
|
69
|
+
code: 'ABORTED',
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('leaves an ignored result unawaited, so a missing responder is not an unhandled rejection', async () => {
|
|
74
|
+
// No responder for the topic: awaiting the result would reject with NO_RESPONDER. Vitest
|
|
75
|
+
// fails the run on an unhandled rejection, so reaching the end of this test is the assertion.
|
|
76
|
+
const {result} = renderHook(() => useEmit('auth.token.refresh'))
|
|
77
|
+
|
|
78
|
+
result.current()
|
|
79
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('can suspend on an emitted reply', async () => {
|
|
83
|
+
let reply: (() => void) | undefined
|
|
84
|
+
host.subscribe('auth.token.refresh', (message) => {
|
|
85
|
+
reply = () => message.reply('token')
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
function Token({request}: {request: MessageBusEmitResult<string>}) {
|
|
89
|
+
return use(request)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function RefreshToken() {
|
|
93
|
+
const emit = useEmit('auth.token.refresh')
|
|
94
|
+
const [request, setRequest] = useState<MessageBusEmitResult<string> | null>(null)
|
|
95
|
+
return (
|
|
96
|
+
<>
|
|
97
|
+
<button onClick={() => setRequest(emit())}>Refresh</button>
|
|
98
|
+
<Suspense fallback="Loading">{request && <Token request={request} />}</Suspense>
|
|
99
|
+
</>
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
render(<RefreshToken />)
|
|
104
|
+
await act(async () => {
|
|
105
|
+
screen.getByRole('button').click()
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
expect(screen.getByText('Loading')).toBeInTheDocument()
|
|
109
|
+
|
|
110
|
+
await act(async () => reply?.())
|
|
111
|
+
|
|
112
|
+
expect(await screen.findByText('token')).toBeInTheDocument()
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('throws when used outside a dashboard application', () => {
|
|
116
|
+
resetMessageBus()
|
|
117
|
+
delete (globalThis as {[MESSAGE_BUS_KEY]?: unknown})[MESSAGE_BUS_KEY]
|
|
118
|
+
vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
119
|
+
|
|
120
|
+
expect(() => renderHook(() => useEmit('auth.token.refresh'))).toThrow(
|
|
121
|
+
'Cannot emit topic "auth.token.refresh" without an installed dashboard message bus',
|
|
122
|
+
)
|
|
123
|
+
})
|
|
124
|
+
})
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import {requireDashboardMessageBus} from '@sanity/sdk/_internal'
|
|
2
|
+
import {
|
|
3
|
+
type EventTopic,
|
|
4
|
+
type MessageBusEmitOptions,
|
|
5
|
+
type MessageBusEmitResult,
|
|
6
|
+
type PayloadOf,
|
|
7
|
+
type ReplyOf,
|
|
8
|
+
} from '@sanity/sdk/dashboard'
|
|
9
|
+
import {useCallback} from 'react'
|
|
10
|
+
|
|
11
|
+
import {useSanityInstance} from '../context/useSanityInstance'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Emits a dashboard event topic, typed to the topic's payload and reply.
|
|
15
|
+
* @public
|
|
16
|
+
*/
|
|
17
|
+
export type TopicEmitter<K extends EventTopic> = (
|
|
18
|
+
...args: PayloadOf<K> extends void
|
|
19
|
+
? [payload?: void, options?: MessageBusEmitOptions]
|
|
20
|
+
: [payload: PayloadOf<K>, options?: MessageBusEmitOptions]
|
|
21
|
+
) => MessageBusEmitResult<ReplyOf<K>>
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Returns a stable function that emits a dashboard event topic.
|
|
25
|
+
*
|
|
26
|
+
* The event is sent immediately. Ignore the lazy result for fire-and-forget delivery, await it
|
|
27
|
+
* inside `useTransition` to track a reply, or read it with React `use` to suspend. A reply
|
|
28
|
+
* that fails (`NO_RESPONDER`, `TIMEOUT`, `ABORTED`) rejects the result; when read with `use`
|
|
29
|
+
* that reaches the nearest error boundary, so pair the `Suspense` with one.
|
|
30
|
+
*
|
|
31
|
+
* @example Fire and forget
|
|
32
|
+
* ```tsx
|
|
33
|
+
* function ExpandPanel() {
|
|
34
|
+
* const setPanelMode = useEmit('panels.mode.set')
|
|
35
|
+
* return (
|
|
36
|
+
* <button onClick={() => setPanelMode({name: 'favorites', mode: 'full'})}>
|
|
37
|
+
* Expand
|
|
38
|
+
* </button>
|
|
39
|
+
* )
|
|
40
|
+
* }
|
|
41
|
+
* ```
|
|
42
|
+
*
|
|
43
|
+
* @example Await a reply with Suspense
|
|
44
|
+
* ```tsx
|
|
45
|
+
* function Session({request}: {request: MessageBusEmitResult<string>}) {
|
|
46
|
+
* use(request)
|
|
47
|
+
* return <p>Ready</p>
|
|
48
|
+
* }
|
|
49
|
+
*
|
|
50
|
+
* function SessionAccordion() {
|
|
51
|
+
* const refreshToken = useEmit('auth.token.refresh')
|
|
52
|
+
* const [request, setRequest] = useState<MessageBusEmitResult<string> | null>(null)
|
|
53
|
+
*
|
|
54
|
+
* return (
|
|
55
|
+
* <details
|
|
56
|
+
* onToggle={(event) => setRequest(event.currentTarget.open ? refreshToken() : null)}
|
|
57
|
+
* >
|
|
58
|
+
* <summary>Session</summary>
|
|
59
|
+
* <ErrorBoundary fallback={<p>Could not refresh</p>}>
|
|
60
|
+
* <Suspense fallback={<p>Refreshing...</p>}>
|
|
61
|
+
* {request && <Session request={request} />}
|
|
62
|
+
* </Suspense>
|
|
63
|
+
* </ErrorBoundary>
|
|
64
|
+
* </details>
|
|
65
|
+
* )
|
|
66
|
+
* }
|
|
67
|
+
* ```
|
|
68
|
+
*
|
|
69
|
+
* @public
|
|
70
|
+
*/
|
|
71
|
+
export function useEmit<K extends EventTopic>(topic: K): TopicEmitter<K> {
|
|
72
|
+
const instance = useSanityInstance()
|
|
73
|
+
const messageBus = requireDashboardMessageBus(instance, `emit topic "${topic}"`)
|
|
74
|
+
return useCallback<TopicEmitter<K>>(
|
|
75
|
+
(...args) => messageBus.emit(topic, ...args),
|
|
76
|
+
[messageBus, topic],
|
|
77
|
+
)
|
|
78
|
+
}
|
|
@@ -45,9 +45,7 @@ import {useWindowConnection} from '../comlink/useWindowConnection'
|
|
|
45
45
|
* }
|
|
46
46
|
* ```
|
|
47
47
|
*/
|
|
48
|
-
export function useNavigate(
|
|
49
|
-
navigateFn: (options: PathChangeMessage['data']) => void,
|
|
50
|
-
): void {
|
|
48
|
+
export function useNavigate(navigateFn: (options: PathChangeMessage['data']) => void): void {
|
|
51
49
|
useWindowConnection<PathChangeMessage, never>({
|
|
52
50
|
name: SDK_NODE_NAME,
|
|
53
51
|
connectTo: SDK_CHANNEL_NAME,
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import {createSanityInstance, type SanityInstance} from '@sanity/sdk'
|
|
2
|
+
import {type ReactNode, StrictMode} from 'react'
|
|
3
|
+
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
|
|
4
|
+
|
|
5
|
+
import {renderHook} from '../../../test/test-utils'
|
|
6
|
+
import {SanityInstanceContext} from '../../context/SanityInstanceContext'
|
|
7
|
+
import {useRemoteClient} from './useRemoteClient'
|
|
8
|
+
|
|
9
|
+
const {createMFInstance} = vi.hoisted(() => ({
|
|
10
|
+
createMFInstance: vi.fn(() => ({
|
|
11
|
+
registerRemotes: vi.fn(),
|
|
12
|
+
loadRemote: vi.fn(),
|
|
13
|
+
preloadRemote: vi.fn(),
|
|
14
|
+
})),
|
|
15
|
+
}))
|
|
16
|
+
|
|
17
|
+
vi.mock('@module-federation/runtime', () => ({createInstance: createMFInstance}))
|
|
18
|
+
|
|
19
|
+
function wrapper(instance: SanityInstance) {
|
|
20
|
+
return function TestProvider({children}: {children: ReactNode}) {
|
|
21
|
+
return (
|
|
22
|
+
<StrictMode>
|
|
23
|
+
<SanityInstanceContext.Provider value={instance}>{children}</SanityInstanceContext.Provider>
|
|
24
|
+
</StrictMode>
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('useRemoteClient', () => {
|
|
30
|
+
const instances: SanityInstance[] = []
|
|
31
|
+
function createTestInstance() {
|
|
32
|
+
const instance = createSanityInstance()
|
|
33
|
+
instances.push(instance)
|
|
34
|
+
return instance
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
beforeEach(() => vi.clearAllMocks())
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
instances.splice(0).forEach((instance) => instance.dispose())
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it("creates a client for the hook's Sanity instance", () => {
|
|
43
|
+
const instance = createTestInstance()
|
|
44
|
+
renderHook(() => useRemoteClient(), {wrapper: wrapper(instance)})
|
|
45
|
+
|
|
46
|
+
expect(createMFInstance).toHaveBeenCalledTimes(1)
|
|
47
|
+
expect(createMFInstance).toHaveBeenCalledWith(
|
|
48
|
+
expect.objectContaining({
|
|
49
|
+
name: `sanity-remote-${instance.instanceId}`,
|
|
50
|
+
remotes: [],
|
|
51
|
+
}),
|
|
52
|
+
)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('does not create a client for a disposed Sanity instance', () => {
|
|
56
|
+
const instance = createTestInstance()
|
|
57
|
+
instance.dispose()
|
|
58
|
+
|
|
59
|
+
expect(() => renderHook(() => useRemoteClient(), {wrapper: wrapper(instance)})).toThrow(
|
|
60
|
+
'Cannot create a remote client for a disposed Sanity instance',
|
|
61
|
+
)
|
|
62
|
+
expect(createMFInstance).not.toHaveBeenCalled()
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('shares a client across consumers and remounts using the same instance', () => {
|
|
66
|
+
const instance = createTestInstance()
|
|
67
|
+
const first = renderHook(() => useRemoteClient(), {wrapper: wrapper(instance)})
|
|
68
|
+
const second = renderHook(() => useRemoteClient(), {wrapper: wrapper(instance)})
|
|
69
|
+
expect(second.result.current).toBe(first.result.current)
|
|
70
|
+
const client = first.result.current
|
|
71
|
+
first.unmount()
|
|
72
|
+
second.unmount()
|
|
73
|
+
|
|
74
|
+
const remounted = renderHook(() => useRemoteClient(), {wrapper: wrapper(instance)})
|
|
75
|
+
expect(remounted.result.current).toBe(client)
|
|
76
|
+
expect(createMFInstance).toHaveBeenCalledTimes(1)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('isolates clients between Sanity instances', () => {
|
|
80
|
+
const firstInstance = createTestInstance()
|
|
81
|
+
const secondInstance = createTestInstance()
|
|
82
|
+
const first = renderHook(() => useRemoteClient(), {wrapper: wrapper(firstInstance)})
|
|
83
|
+
const second = renderHook(() => useRemoteClient(), {wrapper: wrapper(secondInstance)})
|
|
84
|
+
|
|
85
|
+
expect(first.result.current).not.toBe(second.result.current)
|
|
86
|
+
expect(createMFInstance).toHaveBeenCalledTimes(2)
|
|
87
|
+
})
|
|
88
|
+
})
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import {type RemoteInstance} from '../../dashboard/createRemoteInstance'
|
|
2
|
+
import {getRemoteClientState} from '../../dashboard/remoteClientState'
|
|
3
|
+
import {createStateSourceHook} from '../helpers/createStateSourceHook'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Returns a remote client shared by hooks using the same Sanity instance.
|
|
7
|
+
* The first call creates the client; later calls reuse it.
|
|
8
|
+
* @public
|
|
9
|
+
*/
|
|
10
|
+
export const useRemoteClient: () => RemoteInstance = createStateSourceHook(getRemoteClientState)
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import {installMessageBus, resetMessageBus} from '@sanity/sdk/_internal'
|
|
2
|
+
import {
|
|
3
|
+
MessageBusError,
|
|
4
|
+
type MessageBusHost,
|
|
5
|
+
type TopicData,
|
|
6
|
+
TopicError,
|
|
7
|
+
type ValueOf,
|
|
8
|
+
} from '@sanity/sdk/dashboard'
|
|
9
|
+
import {Suspense} from 'react'
|
|
10
|
+
import {ErrorBoundary} from 'react-error-boundary'
|
|
11
|
+
import {afterEach, beforeEach, describe, expect, expectTypeOf, it, vi} from 'vitest'
|
|
12
|
+
|
|
13
|
+
import {act, fireEvent, render, renderHook, screen} from '../../../test/test-utils'
|
|
14
|
+
import {useTopic} from './useTopic'
|
|
15
|
+
|
|
16
|
+
type Applications = Extract<NonNullable<ValueOf<'applications.list'>>, {ok: true}>['value']
|
|
17
|
+
|
|
18
|
+
const MESSAGE_BUS_KEY = Symbol.for('sanity.os.bus')
|
|
19
|
+
|
|
20
|
+
let host: MessageBusHost
|
|
21
|
+
|
|
22
|
+
function Token() {
|
|
23
|
+
return <span>{useTopic('auth.token')}</span>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function ApplicationCount() {
|
|
27
|
+
const applications = useTopic('applications.list')
|
|
28
|
+
return <span>{applications?.length ?? 0} applications</span>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function renderInBoundary(ui: React.ReactNode, onError = vi.fn()) {
|
|
32
|
+
render(
|
|
33
|
+
<ErrorBoundary
|
|
34
|
+
fallbackRender={({resetErrorBoundary}) => <button onClick={resetErrorBoundary}>Retry</button>}
|
|
35
|
+
onError={onError}
|
|
36
|
+
>
|
|
37
|
+
<Suspense fallback="Loading">{ui}</Suspense>
|
|
38
|
+
</ErrorBoundary>,
|
|
39
|
+
)
|
|
40
|
+
return onError
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe('useTopic', () => {
|
|
44
|
+
beforeEach(() => {
|
|
45
|
+
// The SDK resolves its own app ID from the CLI-embedded global.
|
|
46
|
+
vi.stubGlobal('__SANITY_APP_ID__', 'app')
|
|
47
|
+
host = installMessageBus({appId: 'dashboard'})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
afterEach(() => {
|
|
51
|
+
resetMessageBus()
|
|
52
|
+
delete (globalThis as {[MESSAGE_BUS_KEY]?: unknown})[MESSAGE_BUS_KEY]
|
|
53
|
+
vi.unstubAllGlobals()
|
|
54
|
+
vi.useRealTimers()
|
|
55
|
+
vi.restoreAllMocks()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('reads a published value and follows topic updates', () => {
|
|
59
|
+
host.connections.subscribe((client) => client.emit('applications.foreground', null))
|
|
60
|
+
const {result} = renderHook(() => useTopic('applications.foreground'))
|
|
61
|
+
|
|
62
|
+
expectTypeOf(result.current).toEqualTypeOf<string | null>()
|
|
63
|
+
expect(result.current).toBeNull()
|
|
64
|
+
|
|
65
|
+
act(() =>
|
|
66
|
+
host.connections.subscribe((client) =>
|
|
67
|
+
client.emit('applications.foreground', 'application-2'),
|
|
68
|
+
),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
expect(result.current).toBe('application-2')
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('suspends an unseeded topic until its first value', async () => {
|
|
75
|
+
renderInBoundary(<Token />)
|
|
76
|
+
|
|
77
|
+
expect(screen.getByText('Loading')).toBeInTheDocument()
|
|
78
|
+
|
|
79
|
+
await act(async () => {
|
|
80
|
+
host.connections.subscribe((client) => client.emit('auth.token', 'token'))
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
expect(await screen.findByText('token')).toBeInTheDocument()
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('unwraps a successful topic result to its value', () => {
|
|
87
|
+
const applications = [{id: 'application-1'}] as Applications
|
|
88
|
+
host.connections.subscribe((client) =>
|
|
89
|
+
client.emit('applications.list', {ok: true, value: applications}),
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
const {result} = renderHook(() => useTopic('applications.list'))
|
|
93
|
+
|
|
94
|
+
expectTypeOf(result.current).toEqualTypeOf<TopicData<'applications.list'>>()
|
|
95
|
+
expectTypeOf(result.current).toEqualTypeOf<Applications | null>()
|
|
96
|
+
expect(result.current).toBe(applications)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('throws a TopicError to the error boundary when a topic result fails', () => {
|
|
100
|
+
host.connections.subscribe((client) => client.emit('applications.list', {ok: false}))
|
|
101
|
+
|
|
102
|
+
const onError = renderInBoundary(<ApplicationCount />)
|
|
103
|
+
|
|
104
|
+
expect(screen.getByText('Retry')).toBeInTheDocument()
|
|
105
|
+
const error = onError.mock.calls[0][0] as TopicError
|
|
106
|
+
expect(error).toBeInstanceOf(TopicError)
|
|
107
|
+
expect(error.topic).toBe('applications.list')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('throws a TopicError when the first published result fails', async () => {
|
|
111
|
+
const onError = renderInBoundary(<ApplicationCount />)
|
|
112
|
+
expect(screen.getByText('Loading')).toBeInTheDocument()
|
|
113
|
+
|
|
114
|
+
await act(async () => {
|
|
115
|
+
host.connections.subscribe((client) => client.emit('applications.list', {ok: false}))
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
expect(await screen.findByText('Retry')).toBeInTheDocument()
|
|
119
|
+
expect(onError.mock.calls[0][0]).toBeInstanceOf(TopicError)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('throws a TopicError when a later result fails', () => {
|
|
123
|
+
host.connections.subscribe((client) =>
|
|
124
|
+
client.emit('applications.list', {ok: true, value: [] as Applications}),
|
|
125
|
+
)
|
|
126
|
+
const onError = renderInBoundary(<ApplicationCount />)
|
|
127
|
+
expect(screen.getByText('0 applications')).toBeInTheDocument()
|
|
128
|
+
|
|
129
|
+
act(() => host.connections.subscribe((client) => client.emit('applications.list', {ok: false})))
|
|
130
|
+
|
|
131
|
+
expect(screen.getByText('Retry')).toBeInTheDocument()
|
|
132
|
+
expect(onError.mock.calls[0][0]).toBeInstanceOf(TopicError)
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('throws to the error boundary when the query deadline passes', async () => {
|
|
136
|
+
// Only the query deadline is faked; React's scheduler keeps real timers so the retry renders.
|
|
137
|
+
vi.useFakeTimers({toFake: ['setTimeout', 'clearTimeout']})
|
|
138
|
+
|
|
139
|
+
const onError = renderInBoundary(<Token />)
|
|
140
|
+
expect(screen.getByText('Loading')).toBeInTheDocument()
|
|
141
|
+
|
|
142
|
+
await act(async () => {
|
|
143
|
+
await vi.advanceTimersByTimeAsync(5000)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
expect(screen.getByText('Retry')).toBeInTheDocument()
|
|
147
|
+
const error = onError.mock.calls[0][0] as MessageBusError
|
|
148
|
+
expect(error).toBeInstanceOf(MessageBusError)
|
|
149
|
+
expect(error.code).toBe('TIMEOUT')
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('retries with a fresh deadline when the boundary resets after the failure is released', async () => {
|
|
153
|
+
vi.useFakeTimers({toFake: ['setTimeout', 'clearTimeout']})
|
|
154
|
+
|
|
155
|
+
const onError = renderInBoundary(<Token />)
|
|
156
|
+
await act(async () => {
|
|
157
|
+
await vi.advanceTimersByTimeAsync(5000)
|
|
158
|
+
})
|
|
159
|
+
expect(onError).toHaveBeenCalledTimes(1)
|
|
160
|
+
|
|
161
|
+
// The failure is held for a short grace period, then dropped with its last reader.
|
|
162
|
+
await act(async () => {
|
|
163
|
+
await vi.advanceTimersByTimeAsync(1000)
|
|
164
|
+
})
|
|
165
|
+
fireEvent.click(screen.getByText('Retry'))
|
|
166
|
+
expect(screen.getByText('Loading')).toBeInTheDocument()
|
|
167
|
+
|
|
168
|
+
await act(async () => {
|
|
169
|
+
host.connections.subscribe((client) => client.emit('auth.token', 'token'))
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
expect(screen.getByText('token')).toBeInTheDocument()
|
|
173
|
+
expect(onError).toHaveBeenCalledTimes(1)
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('does not throw a failure recorded after the reader unmounted at the next mount', async () => {
|
|
177
|
+
vi.useFakeTimers({toFake: ['setTimeout', 'clearTimeout']})
|
|
178
|
+
|
|
179
|
+
const {unmount} = render(<Suspense fallback="Loading">{<Token />}</Suspense>)
|
|
180
|
+
unmount()
|
|
181
|
+
await act(async () => {
|
|
182
|
+
await vi.advanceTimersByTimeAsync(6000)
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
const onError = renderInBoundary(<Token />)
|
|
186
|
+
|
|
187
|
+
expect(screen.getByText('Loading')).toBeInTheDocument()
|
|
188
|
+
expect(onError).not.toHaveBeenCalled()
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('throws when used outside a dashboard application', () => {
|
|
192
|
+
resetMessageBus()
|
|
193
|
+
delete (globalThis as {[MESSAGE_BUS_KEY]?: unknown})[MESSAGE_BUS_KEY]
|
|
194
|
+
vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
195
|
+
|
|
196
|
+
expect(() => renderHook(() => useTopic('applications.foreground'))).toThrow(
|
|
197
|
+
'Cannot read topic "applications.foreground" without an installed dashboard message bus',
|
|
198
|
+
)
|
|
199
|
+
})
|
|
200
|
+
})
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import {getTopicState, resolveTopic} from '@sanity/sdk/_internal'
|
|
2
|
+
import {type StateTopic, type TopicData} from '@sanity/sdk/dashboard'
|
|
3
|
+
|
|
4
|
+
import {createStateSourceHook} from '../helpers/createStateSourceHook'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Returns the current value of a dashboard state topic and follows later updates.
|
|
8
|
+
*
|
|
9
|
+
* The hook suspends until the topic publishes its first value, using the message bus query
|
|
10
|
+
* deadline. A topic declared with `TopicResult` resolves to its successful value; a
|
|
11
|
+
* failed result throws a `TopicError` to the nearest error boundary.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* ```tsx
|
|
15
|
+
* function ForegroundApplication() {
|
|
16
|
+
* const foregroundId = useTopic('applications.foreground')
|
|
17
|
+
* return <span>{foregroundId ?? 'No application in the foreground'}</span>
|
|
18
|
+
* }
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* @public
|
|
22
|
+
*/
|
|
23
|
+
export const useTopic = createStateSourceHook({
|
|
24
|
+
getState: getTopicState,
|
|
25
|
+
// `getCurrent` throws a recorded read failure, which surfaces it from render like a thrown value.
|
|
26
|
+
shouldSuspend: (instance, topic: StateTopic) =>
|
|
27
|
+
getTopicState(instance, topic).getCurrent() === undefined,
|
|
28
|
+
suspender: resolveTopic,
|
|
29
|
+
}) as <K extends StateTopic>(topic: K) => TopicData<K>
|