@sanity/sdk-react 3.3.0-rc.0 → 3.3.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 (42) hide show
  1. package/dist/_exports/dashboard-internal.d.ts +2 -2
  2. package/dist/_exports/dashboard.d.ts +57 -1
  3. package/dist/_exports/dashboard.d.ts.map +1 -1
  4. package/dist/_exports/dashboard.js +69 -38
  5. package/dist/_exports/dashboard.js.map +1 -1
  6. package/dist/index.d.ts +5 -139
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +27 -145
  9. package/dist/index.js.map +1 -1
  10. package/dist/{useStudioWorkspacesByProjectIdDataset-BZ_Ud887.js → useStudioWorkspacesByProjectIdDataset-B5A5kBH9.js} +85 -5
  11. package/dist/useStudioWorkspacesByProjectIdDataset-B5A5kBH9.js.map +1 -0
  12. package/package.json +8 -8
  13. package/src/_exports/dashboard-internal.ts +6 -1
  14. package/src/_exports/dashboard.test-d.ts +30 -4
  15. package/src/_exports/dashboard.ts +3 -0
  16. package/src/_exports/sdk-react.ts +0 -3
  17. package/src/components/auth/AuthBoundary.test.tsx +2 -81
  18. package/src/components/auth/AuthBoundary.tsx +3 -17
  19. package/src/components/auth/LoginCallback.test.tsx +7 -46
  20. package/src/components/auth/LoginCallback.tsx +4 -22
  21. package/src/hooks/dashboard/useApplicationBasePath.test.tsx +77 -0
  22. package/src/hooks/dashboard/useApplicationBasePath.ts +23 -0
  23. package/src/hooks/dashboard/useApplications.test.tsx +12 -3
  24. package/src/hooks/dashboard/useApplications.ts +5 -24
  25. package/src/hooks/dashboard/useAuthToken.test.tsx +48 -0
  26. package/src/hooks/dashboard/useAuthToken.ts +20 -0
  27. package/src/hooks/dashboard/useCurrentUser.test.tsx +57 -0
  28. package/src/hooks/dashboard/useCurrentUser.ts +22 -0
  29. package/src/hooks/dashboard/useOrganizationId.test.tsx +52 -1
  30. package/src/hooks/dashboard/useOrganizationId.tsx +15 -2
  31. package/src/hooks/dashboard/useStudioWorkspacesByProjectIdDataset.test.tsx +142 -1
  32. package/src/hooks/dashboard/useStudioWorkspacesByProjectIdDataset.ts +58 -1
  33. package/src/hooks/dashboard/useWindowTitle.test.ts +23 -0
  34. package/src/hooks/dashboard/useWindowTitle.ts +14 -0
  35. package/src/hooks/installations/useInstallations.test-d.ts +10 -0
  36. package/dist/useStudioWorkspacesByProjectIdDataset-BZ_Ud887.js.map +0 -1
  37. package/src/hooks/auth/useHandleOAuthCallback.test.tsx +0 -16
  38. package/src/hooks/auth/useHandleOAuthCallback.tsx +0 -49
  39. package/src/hooks/auth/useOAuthAuthorize.test.tsx +0 -16
  40. package/src/hooks/auth/useOAuthAuthorize.tsx +0 -28
  41. package/src/hooks/auth/useOAuthTokens.test.tsx +0 -240
  42. package/src/hooks/auth/useOAuthTokens.tsx +0 -95
@@ -0,0 +1,77 @@
1
+ import {installMessageBus, resetMessageBus} from '@sanity/sdk/_internal'
2
+ import {type MessageBusHost, TopicError} from '@sanity/sdk/dashboard'
3
+ import {Suspense} from 'react'
4
+ import {ErrorBoundary} from 'react-error-boundary'
5
+ import {afterEach, beforeEach, describe, expect, expectTypeOf, it, vi} from 'vitest'
6
+
7
+ import {act, render, screen} from '../../../test/test-utils'
8
+ import {useApplicationBasePath} from './useApplicationBasePath'
9
+
10
+ const MESSAGE_BUS_KEY = Symbol.for('sanity.os.bus')
11
+
12
+ let host: MessageBusHost
13
+
14
+ describe('useApplicationBasePath', () => {
15
+ beforeEach(() => {
16
+ vi.stubGlobal('__SANITY_APP_ID__', 'app')
17
+ host = installMessageBus({appId: 'dashboard'})
18
+ })
19
+
20
+ afterEach(() => {
21
+ resetMessageBus()
22
+ delete (globalThis as {[MESSAGE_BUS_KEY]?: unknown})[MESSAGE_BUS_KEY]
23
+ vi.unstubAllGlobals()
24
+ })
25
+
26
+ it('suspends until Dashboard publishes, then follows updates', async () => {
27
+ function BasePath() {
28
+ const basePath = useApplicationBasePath()
29
+ expectTypeOf(basePath).toEqualTypeOf<string>()
30
+ return <span>{basePath}</span>
31
+ }
32
+
33
+ render(
34
+ <Suspense fallback="Loading">
35
+ <BasePath />
36
+ </Suspense>,
37
+ )
38
+
39
+ expect(screen.getByText('Loading')).toBeInTheDocument()
40
+
41
+ await act(async () => {
42
+ host.connections.subscribe((client) =>
43
+ client.emit('applications.base-path', {ok: true, value: '/application/app'}),
44
+ )
45
+ })
46
+ expect(await screen.findByText('/application/app')).toBeInTheDocument()
47
+
48
+ act(() =>
49
+ host.connections.subscribe((client) =>
50
+ client.emit('applications.base-path', {ok: true, value: '/application/app-2'}),
51
+ ),
52
+ )
53
+ expect(screen.getByText('/application/app-2')).toBeInTheDocument()
54
+ })
55
+
56
+ it('throws a TopicError when the application is unknown', () => {
57
+ host.connections.subscribe((client) => client.emit('applications.base-path', {ok: false}))
58
+ const onError = vi.fn()
59
+
60
+ function BasePath() {
61
+ return <span>{useApplicationBasePath()}</span>
62
+ }
63
+
64
+ render(
65
+ <ErrorBoundary fallback={<span>Failed</span>} onError={onError}>
66
+ <Suspense fallback="Loading">
67
+ <BasePath />
68
+ </Suspense>
69
+ </ErrorBoundary>,
70
+ )
71
+
72
+ expect(screen.getByText('Failed')).toBeInTheDocument()
73
+ const error = onError.mock.calls[0][0] as TopicError
74
+ expect(error).toBeInstanceOf(TopicError)
75
+ expect(error.topic).toBe('applications.base-path')
76
+ })
77
+ })
@@ -0,0 +1,23 @@
1
+ import {type TopicData} from '@sanity/sdk/dashboard'
2
+
3
+ import {useTopic} from './useTopic'
4
+
5
+ /**
6
+ * Returns the base path for an application.
7
+ *
8
+ * Suspends until Dashboard publishes the path and throws a `TopicError` when the application is
9
+ * unknown.
10
+ *
11
+ * @example
12
+ * ```tsx
13
+ * function ApplicationBasePath() {
14
+ * const basePath = useApplicationBasePath()
15
+ * return <span>{basePath}</span>
16
+ * }
17
+ * ```
18
+ *
19
+ * @public
20
+ */
21
+ export function useApplicationBasePath(): TopicData<'applications.base-path'> {
22
+ return useTopic('applications.base-path')
23
+ }
@@ -134,15 +134,24 @@ describe('useApplications', () => {
134
134
  vi.restoreAllMocks()
135
135
  })
136
136
 
137
- it('returns minimal applications with loadable views and web workers', () => {
137
+ it('returns full applications with loadable views and web workers', () => {
138
138
  emitApplications([application, nonFederatedApplication, nonSingletonApplication])
139
139
 
140
140
  const {result} = renderHook(() => useApplications())
141
141
 
142
142
  expectTypeOf(result.current).toEqualTypeOf<DashboardApplication[]>()
143
+ expectTypeOf<
144
+ Extract<keyof DashboardApplication, 'activeDeployment' | 'config'>
145
+ >().toEqualTypeOf<'activeDeployment' | 'config'>()
143
146
  const [federated, nonFederated, nonSingleton] = result.current
144
- expect(federated).not.toHaveProperty('activeDeployment')
145
- expect(federated).not.toHaveProperty('config')
147
+ expect(federated).toMatchObject({
148
+ activeDeployment: {id: 'deployment-1'},
149
+ config: {mfManifest: {}},
150
+ })
151
+ expect(federated?.views[0]?.application).not.toHaveProperty('activeDeployment')
152
+ expect(federated?.views[0]?.application).not.toHaveProperty('config')
153
+ expect(federated?.webWorkers[0]?.application).not.toHaveProperty('activeDeployment')
154
+ expect(federated?.webWorkers[0]?.application).not.toHaveProperty('config')
146
155
  expect(federated?.views).toEqual([
147
156
  expect.objectContaining({
148
157
  application: expect.objectContaining({id: 'application-1'}),
@@ -1,4 +1,5 @@
1
1
  import {type ApplicationBase} from '@sanity/sdk'
2
+ import {getApplicationOrigin} from '@sanity/sdk/_internal'
2
3
  import {type RemoteModuleRef, type ValueOf} from '@sanity/sdk/dashboard'
3
4
  import {useMemo} from 'react'
4
5
 
@@ -38,31 +39,11 @@ export type DashboardWebWorker = Extract<DashboardApplicationInterface, {type: '
38
39
  * The minimal Brett application fields with its loadable views and web workers.
39
40
  * @public
40
41
  */
41
- export type DashboardApplication = ApplicationBase & {
42
+ export type DashboardApplication = DashboardTopicApplication & {
42
43
  readonly views: DashboardView[]
43
44
  readonly webWorkers: DashboardWebWorker[]
44
45
  }
45
46
 
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
47
  // Only a federated deployment (one with a module federation manifest) exposes loadable modules.
67
48
  const loadableInterfaces = ({
68
49
  activeDeployment,
@@ -74,8 +55,8 @@ const toApplication = (application: DashboardTopicApplication): DashboardApplica
74
55
  const {activeDeployment: _activeDeployment, config: _config, ...applicationBase} = application
75
56
  const interfaces = loadableInterfaces(application)
76
57
  // 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: []}
58
+ const entry = interfaces.length === 0 ? null : getApplicationOrigin(applicationBase)
59
+ if (entry === null) return {...application, views: [], webWorkers: []}
79
60
 
80
61
  const views: DashboardView[] = []
81
62
  const webWorkers: DashboardWebWorker[] = []
@@ -103,7 +84,7 @@ const toApplication = (application: DashboardTopicApplication): DashboardApplica
103
84
  } as DashboardView)
104
85
  }
105
86
 
106
- return {...applicationBase, views, webWorkers}
87
+ return {...application, views, webWorkers}
107
88
  }
108
89
 
109
90
  /**
@@ -0,0 +1,48 @@
1
+ import {installMessageBus, resetMessageBus} from '@sanity/sdk/_internal'
2
+ import {type MessageBusHost} from '@sanity/sdk/dashboard'
3
+ import {Suspense} from 'react'
4
+ import {afterEach, beforeEach, describe, expect, expectTypeOf, it, vi} from 'vitest'
5
+
6
+ import {act, render, screen} from '../../../test/test-utils'
7
+ import {useAuthToken} from './useAuthToken'
8
+
9
+ const MESSAGE_BUS_KEY = Symbol.for('sanity.os.bus')
10
+
11
+ let host: MessageBusHost
12
+
13
+ describe('useAuthToken', () => {
14
+ beforeEach(() => {
15
+ // The SDK resolves its own app ID from the CLI-embedded global.
16
+ vi.stubGlobal('__SANITY_APP_ID__', 'app')
17
+ host = installMessageBus({appId: 'dashboard'})
18
+ })
19
+
20
+ afterEach(() => {
21
+ resetMessageBus()
22
+ delete (globalThis as {[MESSAGE_BUS_KEY]?: unknown})[MESSAGE_BUS_KEY]
23
+ vi.unstubAllGlobals()
24
+ })
25
+
26
+ it('suspends until Dashboard publishes, then follows updates', async () => {
27
+ function Token() {
28
+ const token = useAuthToken()
29
+ expectTypeOf(token).toEqualTypeOf<string | null>()
30
+ return <span>{token ?? 'none'}</span>
31
+ }
32
+ render(
33
+ <Suspense fallback="Loading">
34
+ <Token />
35
+ </Suspense>,
36
+ )
37
+
38
+ expect(screen.getByText('Loading')).toBeInTheDocument()
39
+
40
+ await act(async () => {
41
+ host.connections.subscribe((client) => client.emit('auth.token', null))
42
+ })
43
+ expect(await screen.findByText('none')).toBeInTheDocument()
44
+
45
+ act(() => host.connections.subscribe((client) => client.emit('auth.token', 'token-1')))
46
+ expect(screen.getByText('token-1')).toBeInTheDocument()
47
+ })
48
+ })
@@ -0,0 +1,20 @@
1
+ import {useTopic} from './useTopic'
2
+
3
+ /**
4
+ * Returns the session token for the reading connection, or `null` while signed out.
5
+ *
6
+ * Suspends until Dashboard publishes the token.
7
+ *
8
+ * @example
9
+ * ```tsx
10
+ * function AuthToken() {
11
+ * const token = useAuthToken()
12
+ * return <span>{token ?? 'Signed out'}</span>
13
+ * }
14
+ * ```
15
+ *
16
+ * @public
17
+ */
18
+ export function useAuthToken(): string | null {
19
+ return useTopic('auth.token')
20
+ }
@@ -0,0 +1,57 @@
1
+ import {installMessageBus, resetMessageBus} from '@sanity/sdk/_internal'
2
+ import {type MessageBusHost} from '@sanity/sdk/dashboard'
3
+ import {type CurrentUser} from '@sanity/types'
4
+ import {Suspense} from 'react'
5
+ import {afterEach, beforeEach, describe, expect, expectTypeOf, it, vi} from 'vitest'
6
+
7
+ import {act, render, screen} from '../../../test/test-utils'
8
+ import {useCurrentUser} from './useCurrentUser'
9
+
10
+ const MESSAGE_BUS_KEY = Symbol.for('sanity.os.bus')
11
+
12
+ const user: CurrentUser = {
13
+ id: 'user-1',
14
+ name: 'Ada Lovelace',
15
+ email: 'ada@example.com',
16
+ role: '',
17
+ roles: [],
18
+ }
19
+
20
+ let host: MessageBusHost
21
+
22
+ describe('useCurrentUser', () => {
23
+ beforeEach(() => {
24
+ // The SDK resolves its own app ID from the CLI-embedded global.
25
+ vi.stubGlobal('__SANITY_APP_ID__', 'app')
26
+ host = installMessageBus({appId: 'dashboard'})
27
+ })
28
+
29
+ afterEach(() => {
30
+ resetMessageBus()
31
+ delete (globalThis as {[MESSAGE_BUS_KEY]?: unknown})[MESSAGE_BUS_KEY]
32
+ vi.unstubAllGlobals()
33
+ })
34
+
35
+ it('suspends until Dashboard publishes, then follows updates', async () => {
36
+ function User() {
37
+ const current = useCurrentUser()
38
+ expectTypeOf(current).toEqualTypeOf<CurrentUser | null>()
39
+ return <span>{current?.name ?? 'none'}</span>
40
+ }
41
+ render(
42
+ <Suspense fallback="Loading">
43
+ <User />
44
+ </Suspense>,
45
+ )
46
+
47
+ expect(screen.getByText('Loading')).toBeInTheDocument()
48
+
49
+ await act(async () => {
50
+ host.connections.subscribe((client) => client.emit('users.current', null))
51
+ })
52
+ expect(await screen.findByText('none')).toBeInTheDocument()
53
+
54
+ act(() => host.connections.subscribe((client) => client.emit('users.current', user)))
55
+ expect(screen.getByText('Ada Lovelace')).toBeInTheDocument()
56
+ })
57
+ })
@@ -0,0 +1,22 @@
1
+ import {type CurrentUser} from '@sanity/types'
2
+
3
+ import {useTopic} from './useTopic'
4
+
5
+ /**
6
+ * Returns the signed-in user, or `null` while signed out.
7
+ *
8
+ * Suspends until Dashboard publishes the current user.
9
+ *
10
+ * @example
11
+ * ```tsx
12
+ * function CurrentUser() {
13
+ * const user = useCurrentUser()
14
+ * return <span>{user?.name ?? 'Signed out'}</span>
15
+ * }
16
+ * ```
17
+ *
18
+ * @public
19
+ */
20
+ export function useCurrentUser(): CurrentUser | null {
21
+ return useTopic('users.current')
22
+ }
@@ -1,8 +1,11 @@
1
1
  import {getDashboardOrganizationId} 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
5
  import {throwError} from 'rxjs'
4
- import {describe, expect, it, vi} from 'vitest'
6
+ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
5
7
 
8
+ import {act} from '../../../test/test-utils'
6
9
  import {ResourceProvider} from '../../context/ResourceProvider'
7
10
  import {useOrganizationId} from './useOrganizationId'
8
11
 
@@ -11,6 +14,8 @@ vi.mock('@sanity/sdk', async (importOriginal) => {
11
14
  return {...(actual || {}), getDashboardOrganizationId: vi.fn()}
12
15
  })
13
16
 
17
+ const MESSAGE_BUS_KEY = Symbol.for('sanity.os.bus')
18
+
14
19
  describe('useOrganizationId', () => {
15
20
  it('should return undefined when no organization ID is set', () => {
16
21
  const subscribe = vi.fn()
@@ -49,3 +54,49 @@ describe('useOrganizationId', () => {
49
54
  expect(result.current).toBe(mockOrgId)
50
55
  })
51
56
  })
57
+
58
+ describe('useOrganizationId (message bus)', () => {
59
+ let host: MessageBusHost
60
+
61
+ beforeEach(() => {
62
+ vi.stubGlobal('__SANITY_APP_ID__', 'app')
63
+ host = installMessageBus({appId: 'dashboard'})
64
+ })
65
+
66
+ afterEach(() => {
67
+ resetMessageBus()
68
+ delete (globalThis as {[MESSAGE_BUS_KEY]?: unknown})[MESSAGE_BUS_KEY]
69
+ vi.unstubAllGlobals()
70
+ })
71
+
72
+ const wrapper = ({children}: {children: React.ReactNode}) => (
73
+ <ResourceProvider projectId="test-project" dataset="test-dataset" fallback={null}>
74
+ {children}
75
+ </ResourceProvider>
76
+ )
77
+
78
+ it('is undefined before the host publishes the current organization', () => {
79
+ const {result} = renderHook(() => useOrganizationId(), {wrapper})
80
+ expect(result.current).toBeUndefined()
81
+ })
82
+
83
+ it('returns the organization id once the host publishes it', () => {
84
+ const {result} = renderHook(() => useOrganizationId(), {wrapper})
85
+
86
+ act(() =>
87
+ host.connections.subscribe((client) =>
88
+ client.emit('organizations.current', {id: 'org_123', name: 'Org', slug: 'org'}),
89
+ ),
90
+ )
91
+
92
+ expect(result.current).toBe('org_123')
93
+ })
94
+
95
+ it('is undefined when the host publishes no active organization', () => {
96
+ const {result} = renderHook(() => useOrganizationId(), {wrapper})
97
+
98
+ act(() => host.connections.subscribe((client) => client.emit('organizations.current', null)))
99
+
100
+ expect(result.current).toBeUndefined()
101
+ })
102
+ })
@@ -1,13 +1,19 @@
1
- import {getDashboardOrganizationId} from '@sanity/sdk'
1
+ import {getDashboardOrganizationId, type OrganizationBase} from '@sanity/sdk'
2
+ import {getTopicState, isDashboardEnvironment} from '@sanity/sdk/_internal'
2
3
  import {useMemo, useSyncExternalStore} from 'react'
3
4
 
4
5
  import {useSanityInstance} from '../context/useSanityInstance'
5
6
 
7
+ type CurrentOrganization = Pick<OrganizationBase, 'id' | 'name' | 'slug'> | null | undefined
8
+
6
9
  /**
7
10
  * @public
8
11
  *
9
12
  * A React hook that retrieves the dashboard organization ID that is currently selected in the Sanity Dashboard.
10
13
  *
14
+ * Works in both Dashboard runtimes: it reads the `organizations.current` message bus topic when a
15
+ * host has installed the bus, and falls back to the Comlink connection otherwise.
16
+ *
11
17
  * @example
12
18
  * ```tsx
13
19
  * function DashboardComponent() {
@@ -24,7 +30,14 @@ import {useSanityInstance} from '../context/useSanityInstance'
24
30
  */
25
31
  export function useOrganizationId(): string | undefined {
26
32
  const instance = useSanityInstance()
27
- const {subscribe, getCurrent} = useMemo(() => getDashboardOrganizationId(instance), [instance])
33
+ const {subscribe, getCurrent} = useMemo(() => {
34
+ if (!isDashboardEnvironment()) return getDashboardOrganizationId(instance)
35
+ const source = getTopicState(instance, 'organizations.current')
36
+ return {
37
+ subscribe: source.subscribe,
38
+ getCurrent: () => (source.getCurrent() as CurrentOrganization)?.id ?? undefined,
39
+ }
40
+ }, [instance])
28
41
 
29
42
  return useSyncExternalStore(subscribe, getCurrent)
30
43
  }
@@ -1,6 +1,11 @@
1
+ import {installMessageBus, resetMessageBus} from '@sanity/sdk/_internal'
2
+ import {type MessageBusHost, TopicError, type ValueOf} from '@sanity/sdk/dashboard'
1
3
  import {renderHook, waitFor} from '@testing-library/react'
2
- import {describe, expect, it, vi} from 'vitest'
4
+ import {Suspense} from 'react'
5
+ import {ErrorBoundary} from 'react-error-boundary'
6
+ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
3
7
 
8
+ import {render, renderHook as renderHookWithInstance, screen} from '../../../test/test-utils'
4
9
  import {useWindowConnection} from '../comlink/useWindowConnection'
5
10
  import {useStudioWorkspacesByProjectIdDataset} from './useStudioWorkspacesByProjectIdDataset'
6
11
 
@@ -218,3 +223,139 @@ describe('useStudioWorkspacesByResourceId', () => {
218
223
  })
219
224
  })
220
225
  })
226
+
227
+ describe('useStudioWorkspacesByProjectIdDataset (message bus)', () => {
228
+ const MESSAGE_BUS_KEY = Symbol.for('sanity.os.bus')
229
+ let host: MessageBusHost
230
+
231
+ const deployment = {
232
+ id: 'deployment-1',
233
+ applicationId: 'studio-1',
234
+ size: null,
235
+ version: '4.0.0',
236
+ isAutoUpdating: true,
237
+ isActiveDeployment: true,
238
+ deployedBy: null,
239
+ createdAt: '2026-01-01T00:00:00.000Z',
240
+ updatedAt: '2026-01-02T00:00:00.000Z',
241
+ interfaces: [],
242
+ }
243
+
244
+ const workspace = {
245
+ id: 'workspace-1',
246
+ name: 'production',
247
+ title: 'Production',
248
+ subtitle: null,
249
+ projectId: 'project1',
250
+ dataset: 'dataset1',
251
+ schemaDescriptorId: null,
252
+ basePath: '/production',
253
+ icon: null,
254
+ }
255
+
256
+ const studio = {
257
+ id: 'studio-1',
258
+ type: 'studio',
259
+ title: 'My Studio',
260
+ name: 'my-studio',
261
+ reference: 'organization-1/my-studio',
262
+ icon: null,
263
+ isSingleton: false,
264
+ visibility: 'default',
265
+ slug: 'my-studio',
266
+ externalUrl: null,
267
+ organizationId: 'organization-1',
268
+ createdAt: '2026-01-01T00:00:00.000Z',
269
+ updatedAt: '2026-01-02T00:00:00.000Z',
270
+ config: {},
271
+ activeDeployment: {
272
+ ...deployment,
273
+ workspaces: [
274
+ workspace,
275
+ {...workspace, id: 'workspace-2', name: 'staging', title: null, basePath: null},
276
+ {...workspace, id: 'workspace-3', projectId: 'project2', dataset: 'dataset2'},
277
+ ],
278
+ },
279
+ }
280
+
281
+ const coreApp = {
282
+ ...studio,
283
+ id: 'app-1',
284
+ type: 'coreApp',
285
+ activeDeployment: {...deployment, applicationId: 'app-1', workspaces: [workspace]},
286
+ }
287
+
288
+ const emitApplications = (value: unknown[] | null) =>
289
+ host.connections.subscribe((client) =>
290
+ client.emit(
291
+ 'applications.list',
292
+ (value === null ? null : {ok: true, value}) as ValueOf<'applications.list'>,
293
+ ),
294
+ )
295
+
296
+ beforeEach(() => {
297
+ vi.stubGlobal('__SANITY_APP_ID__', 'app')
298
+ host = installMessageBus({appId: 'dashboard'})
299
+ })
300
+
301
+ afterEach(() => {
302
+ resetMessageBus()
303
+ delete (globalThis as {[MESSAGE_BUS_KEY]?: unknown})[MESSAGE_BUS_KEY]
304
+ vi.unstubAllGlobals()
305
+ })
306
+
307
+ it('maps studio workspaces to resources keyed by projectId:dataset', () => {
308
+ emitApplications([studio, coreApp])
309
+
310
+ const {result} = renderHookWithInstance(() => useStudioWorkspacesByProjectIdDataset())
311
+
312
+ expect(useWindowConnection).not.toHaveBeenCalled()
313
+ expect(result.current.error).toBeNull()
314
+ expect(result.current.workspacesByProjectIdAndDataset).toEqual({
315
+ 'project1:dataset1': [
316
+ {
317
+ id: 'workspace-1',
318
+ name: 'production',
319
+ title: 'Production',
320
+ basePath: '/production',
321
+ projectId: 'project1',
322
+ dataset: 'dataset1',
323
+ type: 'studio',
324
+ userApplicationId: 'studio-1',
325
+ url: 'https://my-studio.sanity.studio',
326
+ },
327
+ expect.objectContaining({id: 'workspace-2', title: 'My Studio', basePath: ''}),
328
+ ],
329
+ 'project2:dataset2': [expect.objectContaining({id: 'workspace-3'})],
330
+ })
331
+ })
332
+
333
+ it('returns an empty map when the dashboard clears its applications', () => {
334
+ emitApplications(null)
335
+
336
+ const {result} = renderHookWithInstance(() => useStudioWorkspacesByProjectIdDataset())
337
+
338
+ expect(result.current.workspacesByProjectIdAndDataset).toEqual({})
339
+ })
340
+
341
+ it('throws a TopicError to the error boundary when the dashboard fails to load applications', () => {
342
+ host.connections.subscribe((client) => client.emit('applications.list', {ok: false}))
343
+ const onError = vi.fn()
344
+
345
+ function Workspaces() {
346
+ const {workspacesByProjectIdAndDataset} = useStudioWorkspacesByProjectIdDataset()
347
+ return <span>{Object.keys(workspacesByProjectIdAndDataset).length} workspaces</span>
348
+ }
349
+
350
+ render(
351
+ <ErrorBoundary fallback={<span>Failed</span>} onError={onError}>
352
+ <Suspense fallback="Loading">
353
+ <Workspaces />
354
+ </Suspense>
355
+ </ErrorBoundary>,
356
+ )
357
+
358
+ expect(screen.getByText('Failed')).toBeInTheDocument()
359
+ expect(onError.mock.calls[0][0]).toBeInstanceOf(TopicError)
360
+ })
361
+ })
@@ -1,7 +1,11 @@
1
+ /* eslint-disable react-compiler/react-compiler -- the transport branch in `useStudioWorkspacesByProjectIdDataset` is a deliberate rules-of-hooks exception; the compiler refuses files that disable it */
1
2
  import {SDK_CHANNEL_NAME, SDK_NODE_NAME} from '@sanity/message-protocol'
2
- import {useEffect, useState} from 'react'
3
+ import {getApplicationOrigin, isDashboardEnvironment} from '@sanity/sdk/_internal'
4
+ import {type TopicData} from '@sanity/sdk/dashboard'
5
+ import {useEffect, useMemo, useState} from 'react'
3
6
 
4
7
  import {useWindowConnection} from '../comlink/useWindowConnection'
8
+ import {useTopic} from './useTopic'
5
9
 
6
10
  export interface DashboardResource {
7
11
  id: string
@@ -24,8 +28,13 @@ interface StudioWorkspacesResult {
24
28
  error: string | null
25
29
  }
26
30
 
31
+ type DashboardApplications = NonNullable<TopicData<'applications.list'>>
32
+
27
33
  /**
28
34
  * Hook that fetches studio workspaces and organizes them by projectId:dataset
35
+ *
36
+ * Works in both Dashboard runtimes: it derives workspaces from the `applications.list` message
37
+ * bus topic when a host has installed the bus, and falls back to the Comlink connection otherwise.
29
38
  * @internal
30
39
  *
31
40
  * @example
@@ -59,6 +68,54 @@ interface StudioWorkspacesResult {
59
68
  * ```
60
69
  */
61
70
  export function useStudioWorkspacesByProjectIdDataset(): StudioWorkspacesResult {
71
+ // The branch is stable: the transport is fixed for the page lifetime, so one set of hooks
72
+ // always runs and the other never does.
73
+ // eslint-disable-next-line react-hooks/rules-of-hooks -- transport is fixed for the page lifetime
74
+ if (isDashboardEnvironment()) return useBusStudioWorkspaces()
75
+ // eslint-disable-next-line react-hooks/rules-of-hooks -- transport is fixed for the page lifetime
76
+ return useComlinkStudioWorkspaces()
77
+ }
78
+
79
+ // The legacy Comlink protocol models studios at the workspace level, so each workspace of a
80
+ // studio's active deployment becomes one resource, addressed by the studio's origin.
81
+ function toResources(application: DashboardApplications[number]): DashboardResource[] {
82
+ if (application.type !== 'studio') return []
83
+ const url = getApplicationOrigin(application) ?? ''
84
+ return (application.activeDeployment?.workspaces ?? []).map((workspace) => ({
85
+ id: workspace.id,
86
+ name: workspace.name,
87
+ title: workspace.title ?? application.title,
88
+ basePath: workspace.basePath ?? '',
89
+ projectId: workspace.projectId,
90
+ dataset: workspace.dataset,
91
+ type: 'studio',
92
+ userApplicationId: application.id,
93
+ url,
94
+ }))
95
+ }
96
+
97
+ function toWorkspaceMap(applications: DashboardApplications): WorkspacesByProjectIdDataset {
98
+ const workspaceMap: WorkspacesByProjectIdDataset = {}
99
+ for (const resource of applications.flatMap(toResources)) {
100
+ const key = `${resource.projectId}:${resource.dataset}` as const
101
+ workspaceMap[key] ??= []
102
+ workspaceMap[key].push(resource)
103
+ }
104
+ return workspaceMap
105
+ }
106
+
107
+ // Suspends until the host publishes its application list and throws a `TopicError` on failure,
108
+ // like every bus hook, so `error` is always `null` on this path.
109
+ function useBusStudioWorkspaces(): StudioWorkspacesResult {
110
+ const applications = useTopic('applications.list')
111
+ const workspacesByProjectIdAndDataset = useMemo(
112
+ () => toWorkspaceMap(applications ?? []),
113
+ [applications],
114
+ )
115
+ return {workspacesByProjectIdAndDataset, error: null}
116
+ }
117
+
118
+ function useComlinkStudioWorkspaces(): StudioWorkspacesResult {
62
119
  const [workspacesByProjectIdAndDataset, setWorkspacesByProjectIdAndDataset] =
63
120
  useState<WorkspacesByProjectIdDataset>({})
64
121
  const [error, setError] = useState<string | null>(null)