@sanity/sdk-react 3.4.0-rc.0 → 3.5.0-next.20260922182803

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/_exports/dashboard.d.ts +151 -45
  2. package/dist/_exports/dashboard.d.ts.map +1 -1
  3. package/dist/_exports/dashboard.js +267 -125
  4. package/dist/_exports/dashboard.js.map +1 -1
  5. package/dist/index.d.ts +21 -141
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +66 -147
  8. package/dist/index.js.map +1 -1
  9. package/dist/{useStudioWorkspacesByProjectIdDataset-B5A5kBH9.js → useStudioWorkspacesByProjectIdDataset-DL6_9jgU.js} +61 -4
  10. package/dist/useStudioWorkspacesByProjectIdDataset-DL6_9jgU.js.map +1 -0
  11. package/package.json +9 -9
  12. package/src/_exports/dashboard.test-d.ts +3 -0
  13. package/src/_exports/dashboard.ts +13 -2
  14. package/src/_exports/sdk-react.ts +0 -3
  15. package/src/components/auth/AuthBoundary.test.tsx +2 -81
  16. package/src/components/auth/AuthBoundary.tsx +3 -17
  17. package/src/components/auth/LoginCallback.test.tsx +7 -46
  18. package/src/components/auth/LoginCallback.tsx +4 -22
  19. package/src/hooks/dashboard/useAgentResourceContext.test.tsx +67 -2
  20. package/src/hooks/dashboard/useAgentResourceContext.ts +35 -6
  21. package/src/hooks/dashboard/useApplicationContext.test.tsx +95 -0
  22. package/src/hooks/dashboard/useApplicationContext.ts +47 -0
  23. package/src/hooks/dashboard/useApplications.test.tsx +165 -7
  24. package/src/hooks/dashboard/useApplications.ts +112 -28
  25. package/src/hooks/dashboard/useCapabilities.test.tsx +84 -0
  26. package/src/hooks/dashboard/useCapabilities.ts +27 -0
  27. package/src/hooks/dashboard/useNavigate.test.ts +392 -5
  28. package/src/hooks/dashboard/useNavigate.ts +169 -26
  29. package/src/hooks/dashboard/useNavigateToStudioDocument.test.ts +116 -9
  30. package/src/hooks/dashboard/useNavigateToStudioDocument.ts +110 -39
  31. package/src/hooks/dashboard/useRecordDocumentHistoryEvent.test.ts +99 -1
  32. package/src/hooks/dashboard/useRecordDocumentHistoryEvent.ts +73 -2
  33. package/src/hooks/dashboard/useStudioWorkspacesByProjectIdDataset.test.tsx +23 -1
  34. package/src/hooks/dashboard/useStudioWorkspacesByProjectIdDataset.ts +2 -1
  35. package/dist/useStudioWorkspacesByProjectIdDataset-B5A5kBH9.js.map +0 -1
  36. package/src/hooks/auth/useHandleOAuthCallback.test.tsx +0 -16
  37. package/src/hooks/auth/useHandleOAuthCallback.tsx +0 -49
  38. package/src/hooks/auth/useOAuthAuthorize.test.tsx +0 -16
  39. package/src/hooks/auth/useOAuthAuthorize.tsx +0 -28
  40. package/src/hooks/auth/useOAuthTokens.test.tsx +0 -240
  41. package/src/hooks/auth/useOAuthTokens.tsx +0 -95
@@ -1,7 +1,10 @@
1
1
  import {type DocumentHandle} from '@sanity/sdk'
2
+ import {installMessageBus, resetMessageBus} from '@sanity/sdk/_internal'
3
+ import {type MessageBusHost} from '@sanity/sdk/dashboard'
2
4
  import {renderHook} from '@testing-library/react'
3
- import {beforeEach, describe, expect, it, vi} from 'vitest'
5
+ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
4
6
 
7
+ import {renderHook as renderHookWithInstance} from '../../../test/test-utils'
5
8
  import {useNavigateToStudioDocument} from './useNavigateToStudioDocument'
6
9
 
7
10
  // Mock dependencies
@@ -54,14 +57,6 @@ describe('useNavigateToStudioDocument', () => {
54
57
  }
55
58
  })
56
59
 
57
- it('returns a function and connection status', () => {
58
- const {result} = renderHook(() => useNavigateToStudioDocument(mockDocumentHandle))
59
-
60
- expect(result.current).toEqual({
61
- navigateToStudioDocument: expect.any(Function),
62
- })
63
- })
64
-
65
60
  it('sends correct navigation message when called', () => {
66
61
  const {result} = renderHook(() => useNavigateToStudioDocument(mockDocumentHandle))
67
62
 
@@ -205,3 +200,115 @@ describe('useNavigateToStudioDocument', () => {
205
200
  consoleSpy.mockRestore()
206
201
  })
207
202
  })
203
+
204
+ describe('useNavigateToStudioDocument (message bus)', () => {
205
+ const MESSAGE_BUS_KEY = Symbol.for('sanity.os.bus')
206
+ let host: MessageBusHost
207
+ let updates: {url: string}[]
208
+ let reply: {ok: true} | {ok: false; reason: 'not-navigable' | 'interrupted' | 'failed'}
209
+
210
+ const mockDocumentHandle: DocumentHandle = {
211
+ documentId: 'doc123',
212
+ documentType: 'article',
213
+ projectId: 'project1',
214
+ dataset: 'dataset1',
215
+ }
216
+
217
+ const mockWorkspace = {
218
+ id: 'workspace123',
219
+ name: 'production',
220
+ title: 'Production',
221
+ basePath: '/production',
222
+ projectId: 'project1',
223
+ dataset: 'dataset1',
224
+ type: 'studio',
225
+ userApplicationId: 'studio-app',
226
+ url: 'https://test.sanity.studio',
227
+ }
228
+
229
+ beforeEach(() => {
230
+ // Reset shared mocks so `mockSendMessage` assertions don't depend on test order.
231
+ vi.resetAllMocks()
232
+ updates = []
233
+ reply = {ok: true}
234
+ mockWorkspacesByProjectIdAndDataset = {
235
+ 'project1:dataset1': [mockWorkspace],
236
+ }
237
+ vi.stubGlobal('__SANITY_APP_ID__', 'app')
238
+ host = installMessageBus({appId: 'dashboard'})
239
+ host.subscribe('navigation.location.update', (message) => {
240
+ updates.push(message.payload)
241
+ message.reply(reply)
242
+ })
243
+ })
244
+
245
+ afterEach(() => {
246
+ resetMessageBus()
247
+ delete (globalThis as {[MESSAGE_BUS_KEY]?: unknown})[MESSAGE_BUS_KEY]
248
+ vi.unstubAllGlobals()
249
+ vi.restoreAllMocks()
250
+ })
251
+
252
+ it('emits navigation.location.update with the studio edit intent url', () => {
253
+ const {result} = renderHookWithInstance(() => useNavigateToStudioDocument(mockDocumentHandle))
254
+
255
+ result.current.navigateToStudioDocument()
256
+
257
+ expect(mockSendMessage).not.toHaveBeenCalled()
258
+ expect(updates).toEqual([
259
+ {url: '/studios/studio-app/production/intent/edit/id=doc123;type=article/'},
260
+ ])
261
+ })
262
+
263
+ it('selects the workspace matching the preferred studio url', () => {
264
+ const preferredUrl = 'https://preferred.sanity.studio'
265
+ const mockWorkspace2 = {
266
+ ...mockWorkspace,
267
+ id: 'workspace2',
268
+ name: 'staging',
269
+ userApplicationId: 'preferred-app',
270
+ url: preferredUrl,
271
+ }
272
+ mockWorkspacesByProjectIdAndDataset = {
273
+ 'project1:dataset1': [mockWorkspace, mockWorkspace2],
274
+ }
275
+
276
+ const {result} = renderHookWithInstance(() =>
277
+ useNavigateToStudioDocument(mockDocumentHandle, preferredUrl),
278
+ )
279
+
280
+ result.current.navigateToStudioDocument()
281
+
282
+ expect(updates).toEqual([
283
+ {url: '/studios/preferred-app/staging/intent/edit/id=doc123;type=article/'},
284
+ ])
285
+ })
286
+
287
+ it('warns and does not emit when no workspace is found', () => {
288
+ const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
289
+ mockWorkspacesByProjectIdAndDataset = {}
290
+
291
+ const {result} = renderHookWithInstance(() => useNavigateToStudioDocument(mockDocumentHandle))
292
+ result.current.navigateToStudioDocument()
293
+
294
+ expect(updates).toEqual([])
295
+ expect(consoleSpy).toHaveBeenCalled()
296
+ consoleSpy.mockRestore()
297
+ })
298
+
299
+ it('warns when the host rejects the navigation', async () => {
300
+ const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
301
+ reply = {ok: false, reason: 'not-navigable'}
302
+
303
+ const {result} = renderHookWithInstance(() => useNavigateToStudioDocument(mockDocumentHandle))
304
+ result.current.navigateToStudioDocument()
305
+
306
+ await vi.waitFor(() =>
307
+ expect(consoleSpy).toHaveBeenCalledWith(
308
+ 'Failed to navigate to studio document',
309
+ expect.objectContaining({ok: false, reason: 'not-navigable'}),
310
+ ),
311
+ )
312
+ consoleSpy.mockRestore()
313
+ })
314
+ })
@@ -1,8 +1,12 @@
1
+ /* eslint-disable react-compiler/react-compiler -- the transport branch in `useNavigateToStudioDocument` is a deliberate rules-of-hooks exception; the compiler refuses files that disable it */
1
2
  import {type Bridge, SDK_CHANNEL_NAME, SDK_NODE_NAME} from '@sanity/message-protocol'
2
3
  import {type DocumentHandle} from '@sanity/sdk'
4
+ import {isDashboardEnvironment} from '@sanity/sdk/_internal'
3
5
  import {useCallback} from 'react'
4
6
 
7
+ import {urlFor} from '../../dashboard/urlFor'
5
8
  import {useWindowConnection} from '../comlink/useWindowConnection'
9
+ import {useEmit} from './useEmit'
6
10
  import {
7
11
  type DashboardResource,
8
12
  useStudioWorkspacesByProjectIdDataset,
@@ -16,6 +20,61 @@ export interface NavigateToStudioResult {
16
20
  navigateToStudioDocument: () => void
17
21
  }
18
22
 
23
+ function useComlinkStudioNavigator(
24
+ documentHandle: DocumentHandle,
25
+ ): (workspace: DashboardResource) => void {
26
+ const {sendMessage} = useWindowConnection<Bridge.Navigation.NavigateToResourceMessage, never>({
27
+ name: SDK_NODE_NAME,
28
+ connectTo: SDK_CHANNEL_NAME,
29
+ })
30
+
31
+ return useCallback(
32
+ (workspace: DashboardResource) => {
33
+ const message: Bridge.Navigation.NavigateToResourceMessage = {
34
+ type: 'dashboard/v1/bridge/navigate-to-resource',
35
+ data: {
36
+ resourceId: workspace.id,
37
+ resourceType: 'studio',
38
+ path: `/intent/edit/id=${documentHandle.documentId};type=${documentHandle.documentType}`,
39
+ },
40
+ }
41
+ sendMessage(message.type, message.data)
42
+ },
43
+ [documentHandle, sendMessage],
44
+ )
45
+ }
46
+
47
+ function useBusStudioNavigator(
48
+ documentHandle: DocumentHandle,
49
+ ): (workspace: DashboardResource) => void {
50
+ const navigate = useEmit('navigation.location.update')
51
+
52
+ return useCallback(
53
+ (workspace: DashboardResource) => {
54
+ const url = urlFor
55
+ .studios(workspace.userApplicationId)
56
+ .workspace(workspace.name)
57
+ .intent('edit', {id: documentHandle.documentId, type: documentHandle.documentType})
58
+ .url()
59
+ navigate({url}).then(
60
+ (reply) => {
61
+ if (reply.ok === false) {
62
+ // eslint-disable-next-line no-console
63
+ console.warn('Failed to navigate to studio document', reply)
64
+ }
65
+ },
66
+ // The reply can reject (no responder, timeout, aborted); navigation is best-effort, so
67
+ // warn instead of throwing.
68
+ (error) => {
69
+ // eslint-disable-next-line no-console
70
+ console.warn('Failed to navigate to studio document', error)
71
+ },
72
+ )
73
+ },
74
+ [documentHandle, navigate],
75
+ )
76
+ }
77
+
19
78
  /**
20
79
  * @public
21
80
  *
@@ -24,6 +83,9 @@ export interface NavigateToStudioResult {
24
83
  * Uses the `projectId` and `dataset` properties of the {@link DocumentHandle} you provide to resolve the correct Studio.
25
84
  * This will only work if you have deployed a studio with a workspace with this `projectId` / `dataset` combination.
26
85
  *
86
+ * Works in both Dashboard runtimes: it sends the navigation over the Comlink connection in the
87
+ * iframe runtime and over the message bus in the federated runtime.
88
+ *
27
89
  * @remarks If you write your own Document Handle to pass to this hook (as opposed to a Document Handle generated by another hook),
28
90
  * it must include values for `documentId`, `documentType`, `projectId`, and `dataset`.
29
91
  *
@@ -66,10 +128,13 @@ export function useNavigateToStudioDocument(
66
128
  preferredStudioUrl?: string,
67
129
  ): NavigateToStudioResult {
68
130
  const {workspacesByProjectIdAndDataset} = useStudioWorkspacesByProjectIdDataset()
69
- const {sendMessage} = useWindowConnection<Bridge.Navigation.NavigateToResourceMessage, never>({
70
- name: SDK_NODE_NAME,
71
- connectTo: SDK_CHANNEL_NAME,
72
- })
131
+ // The branch is stable: the transport is fixed for the page lifetime, so one navigator hook
132
+ // always runs and the other never does.
133
+ const sendToWorkspace = isDashboardEnvironment()
134
+ ? // eslint-disable-next-line react-hooks/rules-of-hooks -- transport is fixed for the page lifetime
135
+ useBusStudioNavigator(documentHandle)
136
+ : // eslint-disable-next-line react-hooks/rules-of-hooks -- transport is fixed for the page lifetime
137
+ useComlinkStudioNavigator(documentHandle)
73
138
 
74
139
  const navigateToStudioDocument = useCallback(() => {
75
140
  const {projectId, dataset} = documentHandle
@@ -80,30 +145,11 @@ export function useNavigateToStudioDocument(
80
145
  return
81
146
  }
82
147
 
83
- let workspace: DashboardResource | undefined
84
-
85
- if (preferredStudioUrl) {
86
- // Get workspaces matching the projectId:dataset and any workspaces without projectId/dataset,
87
- // in case there hasn't been a manifest loaded yet
88
- const allWorkspaces = [
89
- ...(workspacesByProjectIdAndDataset[`${projectId}:${dataset}`] || []),
90
- ...(workspacesByProjectIdAndDataset['NO_PROJECT_ID:NO_DATASET'] || []),
91
- ]
92
- workspace = allWorkspaces.find((w) => w.url === preferredStudioUrl)
93
- } else {
94
- const workspaces = workspacesByProjectIdAndDataset[`${projectId}:${dataset}`]
95
- if (workspaces?.length > 1) {
96
- // eslint-disable-next-line no-console
97
- console.warn(
98
- 'Multiple workspaces found for document and no preferred studio url',
99
- documentHandle,
100
- )
101
- // eslint-disable-next-line no-console
102
- console.warn('Using the first one', workspaces[0])
103
- }
104
-
105
- workspace = workspaces?.[0]
106
- }
148
+ const workspace = resolveWorkspace(
149
+ documentHandle,
150
+ workspacesByProjectIdAndDataset,
151
+ preferredStudioUrl,
152
+ )
107
153
 
108
154
  if (!workspace) {
109
155
  // eslint-disable-next-line no-console
@@ -113,19 +159,44 @@ export function useNavigateToStudioDocument(
113
159
  return
114
160
  }
115
161
 
116
- const message: Bridge.Navigation.NavigateToResourceMessage = {
117
- type: 'dashboard/v1/bridge/navigate-to-resource',
118
- data: {
119
- resourceId: workspace.id,
120
- resourceType: 'studio',
121
- path: `/intent/edit/id=${documentHandle.documentId};type=${documentHandle.documentType}`,
122
- },
123
- }
124
-
125
- sendMessage(message.type, message.data)
126
- }, [documentHandle, workspacesByProjectIdAndDataset, sendMessage, preferredStudioUrl])
162
+ sendToWorkspace(workspace)
163
+ }, [documentHandle, workspacesByProjectIdAndDataset, preferredStudioUrl, sendToWorkspace])
127
164
 
128
165
  return {
129
166
  navigateToStudioDocument,
130
167
  }
131
168
  }
169
+
170
+ type WorkspacesByProjectIdDataset = ReturnType<
171
+ typeof useStudioWorkspacesByProjectIdDataset
172
+ >['workspacesByProjectIdAndDataset']
173
+
174
+ function resolveWorkspace(
175
+ documentHandle: DocumentHandle,
176
+ workspacesByProjectIdAndDataset: WorkspacesByProjectIdDataset,
177
+ preferredStudioUrl?: string,
178
+ ): DashboardResource | undefined {
179
+ const {projectId, dataset} = documentHandle
180
+ const key = `${projectId}:${dataset}` as const
181
+
182
+ if (preferredStudioUrl) {
183
+ // Include workspaces without projectId/dataset in case their manifest has not loaded yet.
184
+ const allWorkspaces = [
185
+ ...(workspacesByProjectIdAndDataset[key] || []),
186
+ ...(workspacesByProjectIdAndDataset['NO_PROJECT_ID:NO_DATASET'] || []),
187
+ ]
188
+ return allWorkspaces.find((w) => w.url === preferredStudioUrl)
189
+ }
190
+
191
+ const workspaces = workspacesByProjectIdAndDataset[key]
192
+ if (workspaces && workspaces.length > 1) {
193
+ // eslint-disable-next-line no-console
194
+ console.warn(
195
+ 'Multiple workspaces found for document and no preferred studio url',
196
+ documentHandle,
197
+ )
198
+ // eslint-disable-next-line no-console
199
+ console.warn('Using the first one', workspaces[0])
200
+ }
201
+ return workspaces?.[0]
202
+ }
@@ -1,4 +1,6 @@
1
- import {beforeEach, describe, expect, it, vi} from 'vitest'
1
+ import {installMessageBus, resetMessageBus} from '@sanity/sdk/_internal'
2
+ import {type ApplicationActivity, type MessageBusHost} from '@sanity/sdk/dashboard'
3
+ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
2
4
 
3
5
  import {renderHook} from '../../../test/test-utils'
4
6
  import {useWindowConnection} from '../comlink/useWindowConnection'
@@ -69,3 +71,99 @@ describe('useRecordDocumentHistoryEvent', () => {
69
71
  )
70
72
  })
71
73
  })
74
+
75
+ describe('useRecordDocumentHistoryEvent (message bus)', () => {
76
+ const MESSAGE_BUS_KEY = Symbol.for('sanity.os.bus')
77
+ let host: MessageBusHost
78
+
79
+ const documentHandle = {
80
+ documentId: 'mock-id',
81
+ documentType: 'mock-type',
82
+ resourceType: 'studio' as const,
83
+ resourceId: 'mock-resource-id',
84
+ schemaName: 'production',
85
+ }
86
+
87
+ const emitCapabilities = (value: {history?: boolean}) =>
88
+ host.connections.subscribe((client) => client.emit('applications.capabilities', value))
89
+
90
+ beforeEach(() => {
91
+ vi.stubGlobal('__SANITY_APP_ID__', 'app')
92
+ host = installMessageBus({appId: 'dashboard'})
93
+ emitCapabilities({history: true})
94
+ })
95
+
96
+ afterEach(() => {
97
+ resetMessageBus()
98
+ delete (globalThis as {[MESSAGE_BUS_KEY]?: unknown})[MESSAGE_BUS_KEY]
99
+ vi.unstubAllGlobals()
100
+ })
101
+
102
+ it.each(['viewed', 'edited', 'created', 'deleted'] as const)(
103
+ 'emits a %s document activity event, mapping studios to their dataset resource',
104
+ (eventType) => {
105
+ const activity: ApplicationActivity[] = []
106
+ host.subscribe('applications.activity', (message) => activity.push(message.payload))
107
+
108
+ const {result} = renderHook(() => useRecordDocumentHistoryEvent(documentHandle))
109
+ result.current.recordEvent(eventType)
110
+
111
+ expect(useWindowConnection).not.toHaveBeenCalled()
112
+ expect(activity).toEqual([
113
+ {
114
+ kind: 'document',
115
+ eventType,
116
+ document: {
117
+ id: 'mock-id',
118
+ type: 'mock-type',
119
+ resource: {id: 'mock-resource-id', type: 'dataset', schemaName: 'production'},
120
+ },
121
+ },
122
+ ])
123
+ },
124
+ )
125
+
126
+ it('passes a non-studio resource type through unmapped', () => {
127
+ const activity: ApplicationActivity[] = []
128
+ host.subscribe('applications.activity', (message) => activity.push(message.payload))
129
+
130
+ const {result} = renderHook(() =>
131
+ useRecordDocumentHistoryEvent({...documentHandle, resourceType: 'media-library'}),
132
+ )
133
+ result.current.recordEvent('viewed')
134
+
135
+ expect(activity[0].document.resource.type).toBe('media-library')
136
+ })
137
+
138
+ it.each([{history: false}, {}])(
139
+ 'does not emit when the host does not provide the history capability (%o)',
140
+ (capabilities) => {
141
+ emitCapabilities(capabilities)
142
+ const activity: ApplicationActivity[] = []
143
+ host.subscribe('applications.activity', (message) => activity.push(message.payload))
144
+
145
+ const {result} = renderHook(() => useRecordDocumentHistoryEvent(documentHandle))
146
+ result.current.recordEvent('viewed')
147
+
148
+ expect(activity).toEqual([])
149
+ },
150
+ )
151
+
152
+ it('throws when resourceId is missing', () => {
153
+ expect(() =>
154
+ renderHook(() => useRecordDocumentHistoryEvent({...documentHandle, resourceId: undefined})),
155
+ ).toThrow('resourceId is required to record document history under the message bus')
156
+ })
157
+
158
+ it('keeps recordEvent referentially stable when capabilities re-publish unchanged', () => {
159
+ const {result, rerender} = renderHook(() => useRecordDocumentHistoryEvent(documentHandle))
160
+ const first = result.current.recordEvent
161
+
162
+ // Re-publishing the same value must not churn the callback: a dependency on the whole
163
+ // capabilities object rather than `capabilities.history` would fail here.
164
+ emitCapabilities({history: true})
165
+ rerender()
166
+
167
+ expect(result.current.recordEvent).toBe(first)
168
+ })
169
+ })
@@ -1,3 +1,4 @@
1
+ /* eslint-disable react-compiler/react-compiler -- the transport branch in `useRecordDocumentHistoryEvent` is a deliberate rules-of-hooks exception; the compiler refuses files that disable it */
1
2
  import {
2
3
  type CanvasResource,
3
4
  type Events,
@@ -7,10 +8,13 @@ import {
7
8
  type StudioResource,
8
9
  } from '@sanity/message-protocol'
9
10
  import {type DocumentHandle} from '@sanity/sdk'
11
+ import {isDashboardEnvironment} from '@sanity/sdk/_internal'
10
12
  import {type FrameMessage} from '@sanity/sdk/comlink'
11
13
  import {useCallback} from 'react'
12
14
 
13
15
  import {useWindowConnection} from '../comlink/useWindowConnection'
16
+ import {useEmit} from './useEmit'
17
+ import {useTopic} from './useTopic'
14
18
 
15
19
  interface DocumentInteractionHistory {
16
20
  recordEvent: (eventType: 'viewed' | 'edited' | 'created' | 'deleted') => void
@@ -21,6 +25,11 @@ interface DocumentInteractionHistory {
21
25
  */
22
26
  interface UseRecordDocumentHistoryEventProps extends DocumentHandle {
23
27
  resourceType: StudioResource['type'] | MediaResource['type'] | CanvasResource['type']
28
+ /**
29
+ * The resource the document lives in. Optional for studios over Comlink, where the iframe
30
+ * host fills it from context; always required under the message bus, which has no such
31
+ * context and throws when it is missing.
32
+ */
24
33
  resourceId?: string
25
34
  /**
26
35
  * The name of the schema collection this document belongs to.
@@ -31,8 +40,20 @@ interface UseRecordDocumentHistoryEventProps extends DocumentHandle {
31
40
 
32
41
  /**
33
42
  * @internal
34
- * Hook for managing document interaction history in Sanity Studio.
43
+ * Hook for recording document interaction history in a Dashboard application.
35
44
  * This hook provides functionality to record document interactions.
45
+ *
46
+ * It works in both Dashboard runtimes and picks the transport for the current one:
47
+ *
48
+ * | Runtime | Transport | `resourceId` | Suspends until |
49
+ * | --- | --- | --- | --- |
50
+ * | iframe | Comlink | optional for studios | the node connects |
51
+ * | federated | message bus | required | capabilities publish |
52
+ *
53
+ * Under the message bus the hook always suspends until the host publishes its capabilities;
54
+ * only once they resolve does `recordEvent` run, and it then no-ops on every call when the
55
+ * host does not provide the `history` capability. There is no synchronous no-op path.
56
+ *
36
57
  * @category History
37
58
  * @param documentHandle - The document handle containing document ID and type, like `{_id: '123', _type: 'book'}`
38
59
  * @returns An object containing:
@@ -70,7 +91,18 @@ interface UseRecordDocumentHistoryEventProps extends DocumentHandle {
70
91
  * }
71
92
  * ```
72
93
  */
73
- export function useRecordDocumentHistoryEvent({
94
+ export function useRecordDocumentHistoryEvent(
95
+ props: UseRecordDocumentHistoryEventProps,
96
+ ): DocumentInteractionHistory {
97
+ // The branch is stable: the transport is fixed for the page lifetime, so one set of hooks
98
+ // always runs and the other never does.
99
+ // eslint-disable-next-line react-hooks/rules-of-hooks -- transport is fixed for the page lifetime
100
+ if (isDashboardEnvironment()) return useBusRecordDocumentHistoryEvent(props)
101
+ // eslint-disable-next-line react-hooks/rules-of-hooks -- transport is fixed for the page lifetime
102
+ return useComlinkRecordDocumentHistoryEvent(props)
103
+ }
104
+
105
+ function useComlinkRecordDocumentHistoryEvent({
74
106
  documentId,
75
107
  documentType,
76
108
  resourceType,
@@ -119,3 +151,42 @@ export function useRecordDocumentHistoryEvent({
119
151
  recordEvent,
120
152
  }
121
153
  }
154
+
155
+ function useBusRecordDocumentHistoryEvent({
156
+ documentId,
157
+ documentType,
158
+ resourceType,
159
+ resourceId,
160
+ schemaName,
161
+ }: UseRecordDocumentHistoryEventProps): DocumentInteractionHistory {
162
+ const emitActivity = useEmit('applications.activity')
163
+ const capabilities = useTopic('applications.capabilities')
164
+
165
+ // The bus host has no iframe context to fill `projectId.dataset` from, so the resource must
166
+ // be addressed explicitly.
167
+ if (!resourceId) {
168
+ throw new Error('resourceId is required to record document history under the message bus')
169
+ }
170
+
171
+ // A studio is addressed by its dataset resource over the bus; the workspace name rides along
172
+ // in `schemaName`.
173
+ const type = resourceType === 'studio' ? 'dataset' : resourceType
174
+
175
+ const recordEvent = useCallback(
176
+ (eventType: 'viewed' | 'edited' | 'created' | 'deleted') => {
177
+ if (!capabilities.history) return
178
+ emitActivity({
179
+ kind: 'document',
180
+ eventType,
181
+ document: {
182
+ id: documentId,
183
+ type: documentType,
184
+ resource: {id: resourceId, type, schemaName},
185
+ },
186
+ })
187
+ },
188
+ [capabilities.history, documentId, documentType, emitActivity, resourceId, schemaName, type],
189
+ )
190
+
191
+ return {recordEvent}
192
+ }
@@ -305,7 +305,22 @@ describe('useStudioWorkspacesByProjectIdDataset (message bus)', () => {
305
305
  })
306
306
 
307
307
  it('maps studio workspaces to resources keyed by projectId:dataset', () => {
308
- emitApplications([studio, coreApp])
308
+ // An installation (no `type`) shares the topic; the hook must tolerate it and map only studios.
309
+ const installation = {id: 'installation-1', applicationId: 'app-remote-1'}
310
+ // A dev-server studio is still a studio, addressed by its dev server.
311
+ const localStudio = {
312
+ ...studio,
313
+ id: 'studio-local',
314
+ slug: null,
315
+ externalUrl: 'http://localhost:3333',
316
+ local: {host: 'localhost', port: 3333},
317
+ activeDeployment: {
318
+ ...deployment,
319
+ applicationId: 'studio-local',
320
+ workspaces: [{...workspace, id: 'workspace-local', projectId: 'project3'}],
321
+ },
322
+ }
323
+ emitApplications([studio, coreApp, installation, localStudio])
309
324
 
310
325
  const {result} = renderHookWithInstance(() => useStudioWorkspacesByProjectIdDataset())
311
326
 
@@ -327,6 +342,13 @@ describe('useStudioWorkspacesByProjectIdDataset (message bus)', () => {
327
342
  expect.objectContaining({id: 'workspace-2', title: 'My Studio', basePath: ''}),
328
343
  ],
329
344
  'project2:dataset2': [expect.objectContaining({id: 'workspace-3'})],
345
+ 'project3:dataset1': [
346
+ expect.objectContaining({
347
+ id: 'workspace-local',
348
+ userApplicationId: 'studio-local',
349
+ url: 'http://localhost:3333',
350
+ }),
351
+ ],
330
352
  })
331
353
  })
332
354
 
@@ -79,7 +79,8 @@ export function useStudioWorkspacesByProjectIdDataset(): StudioWorkspacesResult
79
79
  // The legacy Comlink protocol models studios at the workspace level, so each workspace of a
80
80
  // studio's active deployment becomes one resource, addressed by the studio's origin.
81
81
  function toResources(application: DashboardApplications[number]): DashboardResource[] {
82
- if (application.type !== 'studio') return []
82
+ // Installations join the topic union without a `type`; only studios yield workspace resources.
83
+ if (!('type' in application) || application.type !== 'studio') return []
83
84
  const url = getApplicationOrigin(application) ?? ''
84
85
  return (application.activeDeployment?.workspaces ?? []).map((workspace) => ({
85
86
  id: workspace.id,