@sanity/sdk-react 3.4.0-rc.0 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/dist/_exports/dashboard.d.ts +112 -34
  2. package/dist/_exports/dashboard.d.ts.map +1 -1
  3. package/dist/_exports/dashboard.js +219 -114
  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-Bjwi4cLk.js} +60 -3
  10. package/dist/useStudioWorkspacesByProjectIdDataset-Bjwi4cLk.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 +11 -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/useCapabilities.test.tsx +84 -0
  24. package/src/hooks/dashboard/useCapabilities.ts +27 -0
  25. package/src/hooks/dashboard/useNavigate.test.ts +392 -5
  26. package/src/hooks/dashboard/useNavigate.ts +169 -26
  27. package/src/hooks/dashboard/useNavigateToStudioDocument.test.ts +116 -9
  28. package/src/hooks/dashboard/useNavigateToStudioDocument.ts +110 -39
  29. package/src/hooks/dashboard/useRecordDocumentHistoryEvent.test.ts +99 -1
  30. package/src/hooks/dashboard/useRecordDocumentHistoryEvent.ts +73 -2
  31. package/dist/useStudioWorkspacesByProjectIdDataset-B5A5kBH9.js.map +0 -1
  32. package/src/hooks/auth/useHandleOAuthCallback.test.tsx +0 -16
  33. package/src/hooks/auth/useHandleOAuthCallback.tsx +0 -49
  34. package/src/hooks/auth/useOAuthAuthorize.test.tsx +0 -16
  35. package/src/hooks/auth/useOAuthAuthorize.tsx +0 -28
  36. package/src/hooks/auth/useOAuthTokens.test.tsx +0 -240
  37. package/src/hooks/auth/useOAuthTokens.tsx +0 -95
@@ -1,10 +1,13 @@
1
1
  import {type PathChangeMessage} from '@sanity/message-protocol'
2
+ import {installMessageBus, resetMessageBus} from '@sanity/sdk/_internal'
3
+ import {type MessageBusHost, type NavigationLocation, type ValueOf} 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 {act, renderHook as renderHookWithInstance} from '../../../test/test-utils'
5
8
  import {useNavigate} from './useNavigate'
6
9
 
7
- const mockOnMessage = vi.fn()
10
+ const mockSendMessage = vi.fn()
8
11
  let mockMessageHandler: ((data: PathChangeMessage['data']) => void) | undefined
9
12
 
10
13
  vi.mock('../comlink/useWindowConnection', () => {
@@ -12,11 +15,11 @@ vi.mock('../comlink/useWindowConnection', () => {
12
15
  useWindowConnection: ({
13
16
  onMessage,
14
17
  }: {
15
- onMessage: Record<string, (data: PathChangeMessage['data']) => void>
18
+ onMessage?: Record<string, (data: PathChangeMessage['data']) => void>
16
19
  }) => {
17
- mockMessageHandler = onMessage['dashboard/v1/history/change-path']
20
+ mockMessageHandler = onMessage?.['dashboard/v1/history/change-path']
18
21
  return {
19
- onMessage: mockOnMessage,
22
+ sendMessage: mockSendMessage,
20
23
  }
21
24
  },
22
25
  }
@@ -41,4 +44,388 @@ describe('useNavigate', () => {
41
44
 
42
45
  expect(mockNavigateFn).toHaveBeenCalledWith(mockNavigationData)
43
46
  })
47
+
48
+ it('reports an in-app navigation over the Comlink update-url message', () => {
49
+ const {result} = renderHook(() => useNavigate(mockNavigateFn))
50
+
51
+ result.current({path: 'documents/abc'})
52
+
53
+ // jsdom's origin is fixed at http://localhost:3000 in this test env.
54
+ expect(mockSendMessage).toHaveBeenCalledWith(
55
+ 'dashboard/v1/bridge/listeners/history/update-url',
56
+ {url: 'http://localhost:3000/documents/abc'},
57
+ )
58
+ })
59
+ })
60
+
61
+ describe('useNavigate (message bus)', () => {
62
+ const MESSAGE_BUS_KEY = Symbol.for('sanity.os.bus')
63
+ let host: MessageBusHost
64
+ // The single navigation.location.update responder for the describe: it records every payload
65
+ // and replies with whatever `updateReply` currently holds, so a test can force a not-ok reply
66
+ // without registering a second competing responder.
67
+ let updates: {url: string; history?: string}[]
68
+ let updateReply: {ok: true} | {ok: false; reason: 'not-navigable' | 'interrupted' | 'failed'}
69
+
70
+ const emitLocation = (value: NavigationLocation | null) =>
71
+ host.connections.subscribe((client) =>
72
+ client.emit('navigation.location', value as ValueOf<'navigation.location'>),
73
+ )
74
+
75
+ const publishBasePath = () =>
76
+ host.connections.subscribe((client) =>
77
+ client.emit('applications.base-path', {ok: true, value: '/applications/app'}),
78
+ )
79
+
80
+ beforeEach(() => {
81
+ updates = []
82
+ updateReply = {ok: true}
83
+ vi.stubGlobal('__SANITY_APP_ID__', 'app')
84
+ host = installMessageBus({appId: 'dashboard'})
85
+ host.subscribe('navigation.location.update', (message) => {
86
+ updates.push(message.payload)
87
+ message.reply(updateReply)
88
+ })
89
+ })
90
+
91
+ afterEach(() => {
92
+ resetMessageBus()
93
+ delete (globalThis as {[MESSAGE_BUS_KEY]?: unknown})[MESSAGE_BUS_KEY]
94
+ vi.unstubAllGlobals()
95
+ })
96
+
97
+ it('does not fire on the initial replayed location', () => {
98
+ publishBasePath()
99
+ emitLocation({appId: 'app', path: 'documents/abc', transition: null})
100
+
101
+ const navigateFn = vi.fn()
102
+ renderHookWithInstance(() => useNavigate(navigateFn))
103
+
104
+ expect(navigateFn).not.toHaveBeenCalled()
105
+ })
106
+
107
+ it.each([['push'], ['replace']] as const)(
108
+ 'fires once for a host-mediated %s request to this app',
109
+ (navigationType) => {
110
+ publishBasePath()
111
+ emitLocation({appId: 'dashboard', path: 'dashboard', transition: null})
112
+
113
+ const navigateFn = vi.fn()
114
+ renderHookWithInstance(() => useNavigate(navigateFn))
115
+
116
+ const to = {appId: 'app', path: 'documents/abc'}
117
+ act(() => {
118
+ emitLocation({appId: 'dashboard', path: 'dashboard', transition: {navigationType, to}})
119
+ emitLocation({appId: 'app', path: 'documents/abc', transition: {navigationType, to}})
120
+ emitLocation({appId: 'app', path: 'documents/abc', transition: null})
121
+ })
122
+
123
+ expect(navigateFn).toHaveBeenCalledTimes(1)
124
+ expect(navigateFn).toHaveBeenCalledWith({path: 'documents/abc', type: navigationType})
125
+ },
126
+ )
127
+
128
+ it('ignores a transition targeting another app', () => {
129
+ publishBasePath()
130
+ emitLocation({appId: 'dashboard', path: 'dashboard', transition: null})
131
+
132
+ const navigateFn = vi.fn()
133
+ renderHookWithInstance(() => useNavigate(navigateFn))
134
+
135
+ const to = {appId: 'other', path: 'other/route'}
136
+ act(() => {
137
+ emitLocation({
138
+ appId: 'dashboard',
139
+ path: 'dashboard',
140
+ transition: {navigationType: 'push', to},
141
+ })
142
+ emitLocation({appId: 'other', path: 'other/route', transition: {navigationType: 'push', to}})
143
+ emitLocation({appId: 'other', path: 'other/route', transition: null})
144
+ })
145
+
146
+ expect(navigateFn).not.toHaveBeenCalled()
147
+ })
148
+
149
+ it('ignores the app’s own in-app change with no prior transition', () => {
150
+ publishBasePath()
151
+ emitLocation({appId: 'app', path: 'documents/abc', transition: null})
152
+
153
+ const navigateFn = vi.fn()
154
+ renderHookWithInstance(() => useNavigate(navigateFn))
155
+
156
+ act(() => {
157
+ emitLocation({appId: 'app', path: 'documents/def', transition: null})
158
+ })
159
+
160
+ expect(navigateFn).not.toHaveBeenCalled()
161
+ })
162
+
163
+ it('emits navigation.location.update with the joined path and history for an outbound report', async () => {
164
+ publishBasePath()
165
+ emitLocation({appId: 'app', path: 'documents/abc', transition: null})
166
+
167
+ const navigateFn = vi.fn()
168
+ const {result} = renderHookWithInstance(() => useNavigate(navigateFn))
169
+
170
+ await act(async () => {
171
+ result.current({path: 'documents/def', type: 'replace'})
172
+ })
173
+
174
+ expect(updates).toEqual([{url: '/applications/app/documents/def', history: 'replace'}])
175
+ })
176
+
177
+ it('defaults the outbound history to push when no type is given', async () => {
178
+ publishBasePath()
179
+ emitLocation({appId: 'app', path: 'documents/abc', transition: null})
180
+
181
+ const {result} = renderHookWithInstance(() => useNavigate(vi.fn()))
182
+
183
+ await act(async () => {
184
+ result.current({path: 'documents/def'})
185
+ })
186
+
187
+ expect(updates).toEqual([{url: '/applications/app/documents/def', history: 'push'}])
188
+ })
189
+
190
+ it('joins a trailing-slash base and a leading-slash path into a single separator', async () => {
191
+ host.connections.subscribe((client) =>
192
+ client.emit('applications.base-path', {ok: true, value: '/applications/app/'}),
193
+ )
194
+ emitLocation({appId: 'app', path: 'documents/abc', transition: null})
195
+
196
+ const {result} = renderHookWithInstance(() => useNavigate(vi.fn()))
197
+
198
+ await act(async () => {
199
+ result.current({path: '/documents/def'})
200
+ })
201
+
202
+ expect(updates).toEqual([{url: '/applications/app/documents/def', history: 'push'}])
203
+ })
204
+
205
+ it('does not echo the app’s own outbound report but still fires host-initiated commits', () => {
206
+ publishBasePath()
207
+ emitLocation({appId: 'app', path: 'documents/abc', transition: null})
208
+
209
+ const navigateFn = vi.fn()
210
+ const {result} = renderHookWithInstance(() => useNavigate(navigateFn))
211
+
212
+ const own = {appId: 'app', path: 'documents/def'}
213
+ act(() => {
214
+ result.current({path: 'documents/def'})
215
+ emitLocation({
216
+ appId: 'app',
217
+ path: 'documents/abc',
218
+ transition: {navigationType: 'push', to: own},
219
+ })
220
+ emitLocation({
221
+ appId: 'app',
222
+ path: 'documents/def',
223
+ transition: {navigationType: 'push', to: own},
224
+ })
225
+ emitLocation({appId: 'app', path: 'documents/def', transition: null})
226
+ })
227
+
228
+ expect(navigateFn).not.toHaveBeenCalled()
229
+
230
+ const hostTo = {appId: 'app', path: 'documents/ghi'}
231
+ act(() => {
232
+ emitLocation({
233
+ appId: 'app',
234
+ path: 'documents/def',
235
+ transition: {navigationType: 'push', to: hostTo},
236
+ })
237
+ emitLocation({
238
+ appId: 'app',
239
+ path: 'documents/ghi',
240
+ transition: {navigationType: 'push', to: hostTo},
241
+ })
242
+ emitLocation({appId: 'app', path: 'documents/ghi', transition: null})
243
+ })
244
+
245
+ expect(navigateFn).toHaveBeenCalledTimes(1)
246
+ expect(navigateFn).toHaveBeenCalledWith({path: 'documents/ghi', type: 'push'})
247
+ })
248
+
249
+ it('fires a later host-mediated commit to the same path as a suppressed own report', () => {
250
+ publishBasePath()
251
+ emitLocation({appId: 'app', path: 'documents/abc', transition: null})
252
+
253
+ const navigateFn = vi.fn()
254
+ const {result} = renderHookWithInstance(() => useNavigate(navigateFn))
255
+
256
+ const own = {appId: 'app', path: 'documents/def'}
257
+ act(() => {
258
+ result.current({path: 'documents/def'})
259
+ emitLocation({
260
+ appId: 'app',
261
+ path: 'documents/abc',
262
+ transition: {navigationType: 'push', to: own},
263
+ })
264
+ emitLocation({
265
+ appId: 'app',
266
+ path: 'documents/def',
267
+ transition: {navigationType: 'push', to: own},
268
+ })
269
+ emitLocation({appId: 'app', path: 'documents/def', transition: null})
270
+ })
271
+
272
+ expect(navigateFn).not.toHaveBeenCalled()
273
+
274
+ const hostTo = {appId: 'app', path: 'documents/def'}
275
+ act(() => {
276
+ emitLocation({
277
+ appId: 'app',
278
+ path: 'documents/def',
279
+ transition: {navigationType: 'push', to: hostTo},
280
+ })
281
+ emitLocation({
282
+ appId: 'app',
283
+ path: 'documents/def',
284
+ transition: {navigationType: 'push', to: hostTo},
285
+ })
286
+ emitLocation({appId: 'app', path: 'documents/def', transition: null})
287
+ })
288
+
289
+ expect(navigateFn).toHaveBeenCalledTimes(1)
290
+ expect(navigateFn).toHaveBeenCalledWith({path: 'documents/def', type: 'push'})
291
+ })
292
+
293
+ it('fires a host commit to a path whose own report was rejected by the host', async () => {
294
+ publishBasePath()
295
+ emitLocation({appId: 'app', path: 'documents/abc', transition: null})
296
+ updateReply = {ok: false, reason: 'not-navigable'}
297
+
298
+ const navigateFn = vi.fn()
299
+ const {result} = renderHookWithInstance(() => useNavigate(navigateFn))
300
+
301
+ await act(async () => {
302
+ result.current({path: 'documents/def'})
303
+ await Promise.resolve()
304
+ })
305
+
306
+ const hostTo = {appId: 'app', path: 'documents/def'}
307
+ act(() => {
308
+ emitLocation({
309
+ appId: 'app',
310
+ path: 'documents/abc',
311
+ transition: {navigationType: 'push', to: hostTo},
312
+ })
313
+ emitLocation({
314
+ appId: 'app',
315
+ path: 'documents/def',
316
+ transition: {navigationType: 'push', to: hostTo},
317
+ })
318
+ emitLocation({appId: 'app', path: 'documents/def', transition: null})
319
+ })
320
+
321
+ expect(navigateFn).toHaveBeenCalledTimes(1)
322
+ expect(navigateFn).toHaveBeenCalledWith({path: 'documents/def', type: 'push'})
323
+ })
324
+
325
+ it('ignores a commit whose prior transition target path does not match', () => {
326
+ publishBasePath()
327
+ emitLocation({appId: 'app', path: 'documents/abc', transition: null})
328
+
329
+ const navigateFn = vi.fn()
330
+ renderHookWithInstance(() => useNavigate(navigateFn))
331
+
332
+ const to = {appId: 'app', path: 'a'}
333
+ act(() => {
334
+ emitLocation({appId: 'app', path: 'documents/abc', transition: {navigationType: 'push', to}})
335
+ emitLocation({appId: 'app', path: 'b', transition: null})
336
+ })
337
+
338
+ expect(navigateFn).not.toHaveBeenCalled()
339
+ })
340
+
341
+ it('suppresses the echo of an own report made with a leading slash', () => {
342
+ publishBasePath()
343
+ emitLocation({appId: 'app', path: 'documents/abc', transition: null})
344
+
345
+ const navigateFn = vi.fn()
346
+ const {result} = renderHookWithInstance(() => useNavigate(navigateFn))
347
+
348
+ const own = {appId: 'app', path: 'documents/def'}
349
+ act(() => {
350
+ result.current({path: '/documents/def'})
351
+ emitLocation({
352
+ appId: 'app',
353
+ path: 'documents/abc',
354
+ transition: {navigationType: 'push', to: own},
355
+ })
356
+ emitLocation({
357
+ appId: 'app',
358
+ path: 'documents/def',
359
+ transition: {navigationType: 'push', to: own},
360
+ })
361
+ emitLocation({appId: 'app', path: 'documents/def', transition: null})
362
+ })
363
+
364
+ expect(navigateFn).not.toHaveBeenCalled()
365
+ })
366
+
367
+ it('fires host-mediated commits while the base path is unpublished', () => {
368
+ emitLocation({appId: 'dashboard', path: 'dashboard', transition: null})
369
+
370
+ const navigateFn = vi.fn()
371
+ renderHookWithInstance(() => useNavigate(navigateFn))
372
+
373
+ const to = {appId: 'app', path: 'documents/abc'}
374
+ act(() => {
375
+ emitLocation({
376
+ appId: 'dashboard',
377
+ path: 'dashboard',
378
+ transition: {navigationType: 'push', to},
379
+ })
380
+ emitLocation({appId: 'app', path: 'documents/abc', transition: {navigationType: 'push', to}})
381
+ emitLocation({appId: 'app', path: 'documents/abc', transition: null})
382
+ })
383
+
384
+ expect(navigateFn).toHaveBeenCalledTimes(1)
385
+ expect(navigateFn).toHaveBeenCalledWith({path: 'documents/abc', type: 'push'})
386
+ })
387
+
388
+ it('drops an outbound report when the base path is not ok and still fires a later host commit to that path', async () => {
389
+ host.connections.subscribe((client) => client.emit('applications.base-path', {ok: false}))
390
+ emitLocation({appId: 'app', path: 'documents/abc', transition: null})
391
+
392
+ const navigateFn = vi.fn()
393
+ const {result} = renderHookWithInstance(() => useNavigate(navigateFn))
394
+
395
+ await act(async () => {
396
+ result.current({path: 'documents/def'})
397
+ })
398
+
399
+ expect(updates).toEqual([])
400
+
401
+ const hostTo = {appId: 'app', path: 'documents/def'}
402
+ act(() => {
403
+ emitLocation({
404
+ appId: 'app',
405
+ path: 'documents/abc',
406
+ transition: {navigationType: 'push', to: hostTo},
407
+ })
408
+ emitLocation({
409
+ appId: 'app',
410
+ path: 'documents/def',
411
+ transition: {navigationType: 'push', to: hostTo},
412
+ })
413
+ emitLocation({appId: 'app', path: 'documents/def', transition: null})
414
+ })
415
+
416
+ expect(navigateFn).toHaveBeenCalledTimes(1)
417
+ expect(navigateFn).toHaveBeenCalledWith({path: 'documents/def', type: 'push'})
418
+ })
419
+
420
+ it('returns a referentially stable function across re-renders', () => {
421
+ publishBasePath()
422
+ emitLocation({appId: 'app', path: 'documents/abc', transition: null})
423
+
424
+ const {result, rerender} = renderHookWithInstance(() => useNavigate(vi.fn()))
425
+ const first = result.current
426
+
427
+ rerender()
428
+
429
+ expect(result.current).toBe(first)
430
+ })
44
431
  })
@@ -1,52 +1,99 @@
1
- import {type PathChangeMessage, SDK_CHANNEL_NAME, SDK_NODE_NAME} from '@sanity/message-protocol'
1
+ /* eslint-disable react-compiler/react-compiler -- the transport branch in `useNavigate` is a deliberate rules-of-hooks exception; the compiler refuses files that disable it */
2
+ import {
3
+ type Bridge,
4
+ type PathChangeMessage,
5
+ SDK_CHANNEL_NAME,
6
+ SDK_NODE_NAME,
7
+ } from '@sanity/message-protocol'
8
+ import {isDashboardEnvironment, requireDashboardMessageBus} from '@sanity/sdk/_internal'
9
+ import {type NavigationLocation, TopicError} from '@sanity/sdk/dashboard'
10
+ import {useCallback, useEffect, useEffectEvent, useRef} from 'react'
11
+ import {filter, map, pairwise} from 'rxjs'
2
12
 
3
13
  import {useWindowConnection} from '../comlink/useWindowConnection'
14
+ import {useSanityInstance} from '../context/useSanityInstance'
15
+
16
+ type UpdateURLMessage = Bridge.Listeners.History.UpdateURLMessage
17
+
18
+ /**
19
+ * A navigation the Dashboard asks the app to perform, or that the app reports to the Dashboard.
20
+ * `path` is relative to the app's own route, with no leading slash (`'documents/abc'`), and may
21
+ * carry a query string and hash.
22
+ * @public
23
+ */
24
+ export type DashboardNavigation = PathChangeMessage['data']
25
+
26
+ /**
27
+ * Reports an in-app navigation to the Dashboard so the browser URL and the Dashboard's own
28
+ * router follow the app. `type` defaults to `'push'` and only applies in the federated runtime;
29
+ * see {@link useNavigate}.
30
+ * @public
31
+ */
32
+ export type NavigateToDashboardPath = (options: {path: string; type?: 'push' | 'replace'}) => void
4
33
 
5
34
  /**
6
35
  * @public
7
36
  *
8
37
  * A helper hook designed to be injected into routing components for apps within the Dashboard.
9
- * While the Dashboard can usually handle navigation, there are special cases when you
10
- * are already within a target app, and need to navigate to another route inside of that app.
38
+ * It keeps the app's router and the Dashboard in two-way sync: `navigateFn` receives the
39
+ * navigations the Dashboard asks the app to perform, and the returned function reports the app's
40
+ * own in-app navigations back to the Dashboard so the browser URL and the Dashboard's router
41
+ * follow along.
11
42
  *
12
- * For example, your user might "favorite" a document inside of your application.
13
- * If they click on the Dashboard favorites item in the sidebar, and are already within your application,
14
- * there needs to be some way for the dashboard to signal to your application to reroute to where that document was favorited.
43
+ * A navigation the app reports is not echoed back through `navigateFn`. The returned function is
44
+ * referentially stable.
15
45
  *
16
- * This hook is intended to receive those messages, and takes a function to route to the correct path.
46
+ * The two Dashboard runtimes differ:
47
+ * - In an iframe (Comlink), inbound `type` may be `'push'`, `'replace'` or `'pop'`. Reporting is
48
+ * optional because the bridge already forwards the iframe's own `pushState`; a reported `type`
49
+ * is ignored and the host applies the URL as a `replace`.
50
+ * - In a federated app (message bus), inbound `type` is `'push'` or `'replace'`; `'pop'` is never
51
+ * sent. Reporting is required because the app's router does not reach the host, and `type` is
52
+ * honoured. Until the Dashboard publishes the app's base path, reports are dropped with a
53
+ * console warning.
17
54
  *
18
55
  * @param navigateFn - Function to handle navigation; should accept:
19
56
  * - `path`: a string, which will be a relative path (for example, 'my-route')
20
57
  * - `type`: 'push', 'replace', or 'pop', which will be the type of navigation to perform
58
+ * @returns A function the app calls to report its own in-app navigations to the Dashboard.
21
59
  *
22
60
  * @example
23
61
  * ```tsx
24
62
  * import {useNavigate} from '@sanity/sdk-react/dashboard'
25
- * import {BrowserRouter, useNavigate as useRouterNavigate} from 'react-router'
26
- * import {Suspense} from 'react'
63
+ * import {useEffect} from 'react'
64
+ * import {useLocation, useNavigate as useRouterNavigate} from 'react-router'
27
65
  *
28
- * function DashboardNavigationHandler() {
29
- * const navigate = useRouterNavigate()
30
- * useNavigate(({path, type}) => {
31
- * navigate(path, {replace: type === 'replace'})
66
+ * function DashboardNavigationSync() {
67
+ * const routerNavigate = useRouterNavigate()
68
+ * const {pathname, search, hash} = useLocation()
69
+ * const navigate = useNavigate(({path, type}) => {
70
+ * routerNavigate(path, {replace: type === 'replace'})
32
71
  * })
72
+ * useEffect(() => {
73
+ * navigate({path: `${pathname.slice(1)}${search}${hash}`})
74
+ * }, [navigate, pathname, search, hash])
33
75
  * return null
34
76
  * }
35
- *
36
- * // Wrap the component with Suspense since the hook may suspend
37
- * function MyApp() {
38
- * return (
39
- * <BrowserRouter>
40
- * <Suspense>
41
- * <DashboardNavigationHandler />
42
- * </Suspense>
43
- * </BrowserRouter>
44
- * )
45
- * }
46
77
  * ```
47
78
  */
48
- export function useNavigate(navigateFn: (options: PathChangeMessage['data']) => void): void {
49
- useWindowConnection<PathChangeMessage, never>({
79
+ export function useNavigate(
80
+ navigateFn: (options: DashboardNavigation) => void,
81
+ ): NavigateToDashboardPath {
82
+ // The branch is stable: the transport is fixed for the page lifetime, so one set of hooks
83
+ // always runs and the other never does.
84
+ // eslint-disable-next-line react-hooks/rules-of-hooks -- transport is fixed for the page lifetime
85
+ if (isDashboardEnvironment()) return useBusNavigate(navigateFn)
86
+ // eslint-disable-next-line react-hooks/rules-of-hooks -- transport is fixed for the page lifetime
87
+ return useComlinkNavigate(navigateFn)
88
+ }
89
+
90
+ function useComlinkNavigate(
91
+ navigateFn: (options: DashboardNavigation) => void,
92
+ ): NavigateToDashboardPath {
93
+ const {sendMessage} = useWindowConnection<
94
+ UpdateURLMessage | PathChangeMessage,
95
+ PathChangeMessage
96
+ >({
50
97
  name: SDK_NODE_NAME,
51
98
  connectTo: SDK_CHANNEL_NAME,
52
99
  onMessage: {
@@ -55,4 +102,100 @@ export function useNavigate(navigateFn: (options: PathChangeMessage['data']) =>
55
102
  },
56
103
  },
57
104
  })
105
+
106
+ return useCallback<NavigateToDashboardPath>(
107
+ ({path}) =>
108
+ sendMessage('dashboard/v1/bridge/listeners/history/update-url', {
109
+ url: new URL(path, window.location.origin).href,
110
+ }),
111
+ [sendMessage],
112
+ )
113
+ }
114
+
115
+ function joinPath(base: string, path: string): string {
116
+ const root = base.replace(/\/$/, '')
117
+ return path ? `${root}/${path}` : root
118
+ }
119
+
120
+ function useBusNavigate(
121
+ navigateFn: (options: DashboardNavigation) => void,
122
+ ): NavigateToDashboardPath {
123
+ const instance = useSanityInstance()
124
+ const bus = requireDashboardMessageBus(instance, 'navigate')
125
+ const navigate = useEffectEvent(navigateFn)
126
+ // The path of this app's latest outbound request, used to suppress the echo of our own commit.
127
+ const ownRequest = useRef<string | null>(null)
128
+
129
+ useEffect(() => {
130
+ // bus.subscribe, not useTopic: the rule needs the previous emission, and React coalesces
131
+ // store notifications that land in one task, so a snapshot + ref could miss the
132
+ // `{to, transition}` -> `{to, transition: null}` pair. The observable delivers every emission.
133
+ const sub = bus
134
+ .subscribe('navigation.location')
135
+ .pipe(
136
+ pairwise(),
137
+ filter(
138
+ (
139
+ pair,
140
+ ): pair is [
141
+ NavigationLocation & {transition: NonNullable<NavigationLocation['transition']>},
142
+ NavigationLocation & {transition: null},
143
+ ] => {
144
+ const [prev, curr] = pair
145
+ return (
146
+ curr?.transition === null &&
147
+ curr.appId === bus.appId &&
148
+ prev?.transition?.to.appId === bus.appId &&
149
+ prev.transition.to.path === curr.path
150
+ )
151
+ },
152
+ ),
153
+ filter(([, curr]) => {
154
+ // Loop suppression: any own-app host-mediated commit consumes the pending request, so
155
+ // a request that lands elsewhere (or never lands) can't strand its path and drop a
156
+ // later host navigation. Only the commit for our exact path is our own echo to drop.
157
+ const suppress = curr.path === ownRequest.current
158
+ ownRequest.current = null
159
+ return !suppress
160
+ }),
161
+ map(([prev, curr]) => ({path: curr.path, type: prev.transition.navigationType})),
162
+ )
163
+ .subscribe((change) => navigate(change))
164
+ return () => sub.unsubscribe()
165
+ }, [bus])
166
+
167
+ return useCallback<NavigateToDashboardPath>(
168
+ ({path, type = 'push'}) => {
169
+ // The host commits paths without a leading slash; store the same form so the echo matches.
170
+ const own = path.replace(/^\//, '')
171
+ ownRequest.current = own
172
+ // A request that never landed must drop its suppression, or a later host navigation to the
173
+ // same path would be dropped. Best-effort: never throws.
174
+ const clearIfStale = () => {
175
+ if (ownRequest.current === own) ownRequest.current = null
176
+ }
177
+ // The base path is read per call, not in render, so a host that has not published it only
178
+ // loses outbound reporting; the inbound subscription above still installs.
179
+ bus
180
+ .query('applications.base-path')
181
+ .then((base) => {
182
+ if (!base.ok) throw new TopicError('applications.base-path')
183
+ return bus.emit('navigation.location.update', {
184
+ url: joinPath(base.value, own),
185
+ history: type,
186
+ })
187
+ })
188
+ .then(
189
+ (reply) => {
190
+ if (!reply.ok) clearIfStale()
191
+ },
192
+ (error) => {
193
+ // eslint-disable-next-line no-console
194
+ console.warn('Failed to report navigation to the Dashboard', error)
195
+ clearIfStale()
196
+ },
197
+ )
198
+ },
199
+ [bus],
200
+ )
58
201
  }