@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.
Files changed (54) hide show
  1. package/dist/_exports/dashboard-internal.d.ts +2 -0
  2. package/dist/_exports/dashboard-internal.js +2 -0
  3. package/dist/_exports/dashboard.d.ts +457 -7
  4. package/dist/_exports/dashboard.d.ts.map +1 -1
  5. package/dist/_exports/dashboard.js +623 -2
  6. package/dist/_exports/dashboard.js.map +1 -1
  7. package/dist/index.d.ts +90 -87
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +4 -5
  10. package/dist/index.js.map +1 -1
  11. package/dist/{useStudioWorkspacesByProjectIdDataset-DxUlukmF.js → useStudioWorkspacesByProjectIdDataset-BZ_Ud887.js} +53 -56
  12. package/dist/useStudioWorkspacesByProjectIdDataset-BZ_Ud887.js.map +1 -0
  13. package/package.json +34 -32
  14. package/src/_exports/dashboard-internal.ts +1 -0
  15. package/src/_exports/dashboard.test-d.ts +64 -0
  16. package/src/_exports/dashboard.ts +70 -0
  17. package/src/_exports/index.ts +1 -1
  18. package/src/components/auth/AuthBoundary.test.tsx +34 -0
  19. package/src/components/auth/AuthBoundary.tsx +1 -2
  20. package/src/context/DashboardTokenRefresh.test.tsx +124 -22
  21. package/src/context/DashboardTokenRefresh.tsx +31 -15
  22. package/src/dashboard/createRemoteInstance.test.ts +168 -0
  23. package/src/dashboard/createRemoteInstance.ts +123 -0
  24. package/src/dashboard/module.ts +39 -0
  25. package/src/dashboard/remoteClientState.ts +27 -0
  26. package/src/dashboard/urlFor.test.ts +246 -0
  27. package/src/dashboard/urlFor.ts +462 -0
  28. package/src/hooks/applications/useApplication.test-d.ts +1 -1
  29. package/src/hooks/dashboard/useApplication.test.tsx +59 -0
  30. package/src/hooks/dashboard/useApplication.ts +22 -0
  31. package/src/hooks/dashboard/useApplicationConfig.test.tsx +106 -0
  32. package/src/hooks/dashboard/useApplicationConfig.ts +45 -0
  33. package/src/hooks/dashboard/useApplicationConfigs.test.tsx +89 -0
  34. package/src/hooks/dashboard/useApplicationConfigs.ts +26 -0
  35. package/src/hooks/dashboard/useApplicationForegroundId.test.tsx +54 -0
  36. package/src/hooks/dashboard/useApplicationForegroundId.ts +22 -0
  37. package/src/hooks/dashboard/useApplications.test.tsx +249 -0
  38. package/src/hooks/dashboard/useApplications.ts +128 -0
  39. package/src/hooks/dashboard/useEmit.test.tsx +124 -0
  40. package/src/hooks/dashboard/useEmit.ts +78 -0
  41. package/src/hooks/dashboard/useNavigate.ts +1 -3
  42. package/src/hooks/dashboard/useRemoteClient.test.tsx +88 -0
  43. package/src/hooks/dashboard/useRemoteClient.ts +10 -0
  44. package/src/hooks/dashboard/useTopic.test.tsx +200 -0
  45. package/src/hooks/dashboard/useTopic.ts +29 -0
  46. package/src/hooks/dashboard/useUpdateFavorite.test.tsx +8 -1
  47. package/src/hooks/document/useApplyDocumentActions.ts +3 -4
  48. package/src/hooks/document/useCreateDocument.ts +2 -3
  49. package/src/hooks/document/useDocument.ts +11 -6
  50. package/src/hooks/document/useEditDocument.ts +5 -5
  51. package/src/hooks/projection/useDocumentProjection.ts +6 -7
  52. package/src/hooks/query/useQuery.ts +3 -4
  53. package/dist/useStudioWorkspacesByProjectIdDataset-DxUlukmF.js.map +0 -1
  54. package/src/context/dashboardToken.ts +0 -63
@@ -1,17 +1,25 @@
1
1
  import {AuthStateType, setAuthToken} from '@sanity/sdk'
2
+ import {getDashboardMessageBus} from '@sanity/sdk/_internal'
2
3
  import {act, render} from '@testing-library/react'
3
4
  import {of} from 'rxjs'
4
5
  import {afterEach, beforeEach, describe, expect, it, type Mock, vi} from 'vitest'
5
6
 
7
+ import {getDashboardModuleContext} from '../dashboard/module'
6
8
  import {useAuthState} from '../hooks/auth/useAuthState'
7
- import {
8
- isDashboardEnvironment,
9
- observeDashboardToken,
10
- refreshDashboardToken,
11
- } from './dashboardToken'
9
+ import {useSanityInstance} from '../hooks/context/useSanityInstance'
12
10
  import {DashboardTokenRefreshProvider} from './DashboardTokenRefresh'
13
11
  import {ResourceProvider} from './ResourceProvider'
14
12
 
13
+ const messageBus = vi.hoisted(() => ({
14
+ client: undefined as
15
+ | undefined
16
+ | {emit: ReturnType<typeof vi.fn>; subscribe: ReturnType<typeof vi.fn>},
17
+ emit: vi.fn(),
18
+ subscribe: vi.fn(),
19
+ // A spy so tests can observe the forwarded arguments.
20
+ getDashboardMessageBus: vi.fn(),
21
+ }))
22
+
15
23
  vi.mock('@sanity/sdk', async () => {
16
24
  const actual = await vi.importActual('@sanity/sdk')
17
25
  return {
@@ -24,17 +32,13 @@ vi.mock('../hooks/auth/useAuthState', () => ({
24
32
  useAuthState: vi.fn(),
25
33
  }))
26
34
 
27
- vi.mock('./dashboardToken', () => ({
28
- isDashboardEnvironment: vi.fn(() => false),
29
- observeDashboardToken: vi.fn(() => undefined),
30
- refreshDashboardToken: vi.fn(),
35
+ vi.mock('@sanity/sdk/_internal', async (importOriginal) => ({
36
+ ...(await importOriginal<typeof import('@sanity/sdk/_internal')>()),
37
+ getDashboardMessageBus: messageBus.getDashboardMessageBus,
31
38
  }))
32
39
 
33
40
  const mockSetAuthToken = setAuthToken as Mock
34
41
  const mockUseAuthState = useAuthState as Mock
35
- const mockIsDashboardEnvironment = isDashboardEnvironment as Mock
36
- const mockObserveDashboardToken = observeDashboardToken as Mock
37
- const mockRefreshDashboardToken = refreshDashboardToken as Mock
38
42
 
39
43
  const renderProvider = () =>
40
44
  render(
@@ -47,6 +51,9 @@ const renderProvider = () =>
47
51
 
48
52
  describe('DashboardTokenRefreshProvider', () => {
49
53
  beforeEach(() => {
54
+ messageBus.client = undefined
55
+ messageBus.getDashboardMessageBus.mockReset()
56
+ messageBus.getDashboardMessageBus.mockImplementation(() => messageBus.client)
50
57
  mockUseAuthState.mockReturnValue({type: AuthStateType.LOGGED_IN})
51
58
  })
52
59
 
@@ -55,9 +62,7 @@ describe('DashboardTokenRefreshProvider', () => {
55
62
  })
56
63
 
57
64
  describe('when not in the dashboard', () => {
58
- it('does not subscribe to the OS token', () => {
59
- mockIsDashboardEnvironment.mockReturnValue(false)
60
-
65
+ it('does not subscribe to a dashboard token', () => {
61
66
  act(() => {
62
67
  renderProvider()
63
68
  })
@@ -68,12 +73,13 @@ describe('DashboardTokenRefreshProvider', () => {
68
73
 
69
74
  describe('when in the dashboard', () => {
70
75
  beforeEach(() => {
71
- mockIsDashboardEnvironment.mockReturnValue(true)
76
+ messageBus.client = messageBus
77
+ messageBus.subscribe.mockReturnValue(of('dashboard-token'))
78
+ // `emit` returns a lazily-awaited reply the provider attaches a `.catch` to.
79
+ messageBus.emit.mockReturnValue(Promise.resolve(undefined))
72
80
  })
73
81
 
74
- it('mirrors the OS token into the auth store', () => {
75
- mockObserveDashboardToken.mockReturnValue(of('dashboard-token'))
76
-
82
+ it('mirrors the dashboard token into the auth store', () => {
77
83
  act(() => {
78
84
  renderProvider()
79
85
  })
@@ -81,9 +87,80 @@ describe('DashboardTokenRefreshProvider', () => {
81
87
  expect(mockSetAuthToken).toHaveBeenCalledWith(expect.anything(), 'dashboard-token')
82
88
  })
83
89
 
84
- it('asks the OS to reissue the token on a 401', () => {
85
- mockObserveDashboardToken.mockReturnValue(of('dashboard-token'))
90
+ it('renders children bare when no host bus is installed', () => {
91
+ messageBus.client = undefined
92
+
93
+ act(() => {
94
+ renderProvider()
95
+ })
96
+
97
+ expect(messageBus.getDashboardMessageBus).toHaveBeenCalledTimes(1)
98
+ expect(mockSetAuthToken).not.toHaveBeenCalled()
99
+ })
100
+
101
+ it('connects with the module id before any child reads the bus during render', () => {
102
+ // getDashboardMessageBus is first-caller-wins per instance. A hook reading the bus in
103
+ // its render runs before any parent effect, so the provider must connect during render
104
+ // or the connection is pinned to the app id.
105
+ const ModuleContext = getDashboardModuleContext()
106
+ function ReadsBus() {
107
+ getDashboardMessageBus(useSanityInstance())
108
+ return null
109
+ }
110
+
111
+ act(() => {
112
+ render(
113
+ <ModuleContext.Provider value="favorites/views/list/panel">
114
+ <ResourceProvider projectId="test-project" dataset="test-dataset" fallback={null}>
115
+ <DashboardTokenRefreshProvider>
116
+ <ReadsBus />
117
+ </DashboardTokenRefreshProvider>
118
+ </ResourceProvider>
119
+ </ModuleContext.Provider>,
120
+ )
121
+ })
122
+
123
+ expect(messageBus.getDashboardMessageBus.mock.calls[0]).toEqual([
124
+ expect.anything(),
125
+ 'favorites/views/list/panel',
126
+ ])
127
+ })
128
+
129
+ it('forwards the module id from the dashboard module context', () => {
130
+ const ModuleContext = getDashboardModuleContext()
131
+
132
+ act(() => {
133
+ render(
134
+ <ModuleContext.Provider value="favorites/views/list/panel">
135
+ <ResourceProvider projectId="test-project" dataset="test-dataset" fallback={null}>
136
+ <DashboardTokenRefreshProvider>
137
+ <div>Test</div>
138
+ </DashboardTokenRefreshProvider>
139
+ </ResourceProvider>
140
+ </ModuleContext.Provider>,
141
+ )
142
+ })
143
+
144
+ expect(messageBus.getDashboardMessageBus).toHaveBeenCalledWith(
145
+ expect.anything(),
146
+ 'favorites/views/list/panel',
147
+ )
148
+ })
149
+
150
+ it('treats subscription failures as a missing token', () => {
151
+ messageBus.subscribe.mockImplementationOnce(() => {
152
+ throw new Error('Incompatible message bus')
153
+ })
154
+
155
+ expect(() => {
156
+ act(() => {
157
+ renderProvider()
158
+ })
159
+ }).not.toThrow()
160
+ expect(mockSetAuthToken).toHaveBeenCalledWith(expect.anything(), null)
161
+ })
86
162
 
163
+ it('asks the message bus to reissue the token on a 401', () => {
87
164
  const {rerender} = renderProvider()
88
165
 
89
166
  mockUseAuthState.mockReturnValue({
@@ -100,7 +177,32 @@ describe('DashboardTokenRefreshProvider', () => {
100
177
  )
101
178
  })
102
179
 
103
- expect(mockRefreshDashboardToken).toHaveBeenCalledTimes(1)
180
+ expect(messageBus.emit).toHaveBeenCalledWith('auth.token.refresh', undefined)
181
+ })
182
+
183
+ it('logs a failed token reissue instead of dropping it silently', async () => {
184
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
185
+ messageBus.emit.mockReturnValueOnce(Promise.reject(new Error('NO_RESPONDER')))
186
+ const {rerender} = renderProvider()
187
+
188
+ mockUseAuthState.mockReturnValue({
189
+ type: AuthStateType.ERROR,
190
+ error: {statusCode: 401, message: 'Unauthorized'},
191
+ })
192
+ await act(async () => {
193
+ rerender(
194
+ <ResourceProvider projectId="test-project" dataset="test-dataset" fallback={null}>
195
+ <DashboardTokenRefreshProvider>
196
+ <div>Test</div>
197
+ </DashboardTokenRefreshProvider>
198
+ </ResourceProvider>,
199
+ )
200
+ })
201
+
202
+ expect(warn).toHaveBeenCalledWith(
203
+ '[sanity/sdk] Dashboard token refresh failed:',
204
+ expect.any(Error),
205
+ )
104
206
  })
105
207
  })
106
208
  })
@@ -1,14 +1,14 @@
1
1
  import {type ClientError} from '@sanity/client'
2
2
  import {AuthStateType, setAuthToken} from '@sanity/sdk'
3
- import React, {type PropsWithChildren, useEffect, useRef} from 'react'
3
+ import {getDashboardMessageBus} from '@sanity/sdk/_internal'
4
+ import {type MessageBus} from '@sanity/sdk/dashboard'
5
+ import React, {type PropsWithChildren, useContext, useEffect, useRef, useState} from 'react'
6
+ import {defer, of} from 'rxjs'
7
+ import {catchError} from 'rxjs/operators'
4
8
 
9
+ import {getDashboardModuleContext} from '../dashboard/module'
5
10
  import {useAuthState} from '../hooks/auth/useAuthState'
6
11
  import {useSanityInstance} from '../hooks/context/useSanityInstance'
7
- import {
8
- isDashboardEnvironment,
9
- observeDashboardToken,
10
- refreshDashboardToken,
11
- } from './dashboardToken'
12
12
 
13
13
  /**
14
14
  * Keeps the SDK auth token in sync with the dashboard "OS".
@@ -20,17 +20,20 @@ import {
20
20
  * (the token expired), we ask the OS to reissue rather than tearing the session
21
21
  * down; the new token arrives back through the same subscription.
22
22
  */
23
- function DashboardTokenRefresh({children}: PropsWithChildren) {
23
+ function DashboardTokenRefresh({
24
+ children,
25
+ messageBus,
26
+ }: PropsWithChildren<{messageBus: MessageBus}>) {
24
27
  const instance = useSanityInstance()
25
28
  const authState = useAuthState()
26
29
  const processed401ErrorRef = useRef<unknown | null>(null)
27
30
 
28
31
  useEffect(() => {
29
- const token$ = observeDashboardToken()
30
- if (!token$) return undefined
31
- const subscription = token$.subscribe((token) => setAuthToken(instance, token))
32
+ const subscription = defer(() => messageBus.subscribe('auth.token'))
33
+ .pipe(catchError(() => of(null)))
34
+ .subscribe((token) => setAuthToken(instance, token))
32
35
  return () => subscription.unsubscribe()
33
- }, [instance])
36
+ }, [instance, messageBus])
34
37
 
35
38
  useEffect(() => {
36
39
  const has401Error =
@@ -38,11 +41,15 @@ function DashboardTokenRefresh({children}: PropsWithChildren) {
38
41
 
39
42
  if (has401Error && processed401ErrorRef.current !== authState.error) {
40
43
  processed401ErrorRef.current = authState.error
41
- refreshDashboardToken()
44
+ // Event topics have no replay, so a missing responder or timeout is otherwise dropped silently.
45
+ messageBus.emit('auth.token.refresh', undefined).catch((error) => {
46
+ // eslint-disable-next-line no-console
47
+ console.warn('[sanity/sdk] Dashboard token refresh failed:', error)
48
+ })
42
49
  } else if (!has401Error) {
43
50
  processed401ErrorRef.current = null
44
51
  }
45
- }, [authState])
52
+ }, [authState, messageBus])
46
53
 
47
54
  return children
48
55
  }
@@ -87,8 +94,17 @@ function DashboardTokenRefresh({children}: PropsWithChildren) {
87
94
  * @public
88
95
  */
89
96
  export const DashboardTokenRefreshProvider: React.FC<PropsWithChildren> = ({children}) => {
90
- if (isDashboardEnvironment()) {
91
- return <DashboardTokenRefresh>{children}</DashboardTokenRefresh>
97
+ const instance = useSanityInstance()
98
+ const moduleId = useContext(getDashboardModuleContext())
99
+ // The connection is first-caller-wins per instance, and hooks below read it during their
100
+ // render, before any effect here could run. Connecting in the first render pins the module
101
+ // identity before they do; nothing has subscribed to the store yet, so the write is safe.
102
+ // The module id is read once: the CLI wrapper provides it statically above this tree.
103
+ // No retry: the host installs the bus at module evaluation, before any remote renders, and
104
+ // a standalone app has no host to wait for.
105
+ const [messageBus] = useState(() => getDashboardMessageBus(instance, moduleId))
106
+ if (messageBus) {
107
+ return <DashboardTokenRefresh messageBus={messageBus}>{children}</DashboardTokenRefresh>
92
108
  }
93
109
 
94
110
  return children
@@ -0,0 +1,168 @@
1
+ import {
2
+ type ModuleFederationRuntimePlugin,
3
+ type UserOptions,
4
+ } from '@module-federation/runtime/types'
5
+ import {beforeEach, describe, expect, it, vi} from 'vitest'
6
+
7
+ import {createRemoteInstance} from './createRemoteInstance'
8
+
9
+ const {
10
+ onDebug,
11
+ mockCreateLogger,
12
+ mockRegisterRemotes,
13
+ mockLoadRemote,
14
+ mockPreloadRemote,
15
+ mockCreateMFInstance,
16
+ } = vi.hoisted(() => {
17
+ const registerRemotes = vi.fn()
18
+ const loadRemote = vi.fn()
19
+ const preloadRemote = vi.fn()
20
+ const debug = vi.fn()
21
+ return {
22
+ onDebug: debug,
23
+ mockCreateLogger: vi.fn(() => ({debug})),
24
+ mockRegisterRemotes: registerRemotes,
25
+ mockLoadRemote: loadRemote,
26
+ mockPreloadRemote: preloadRemote,
27
+ mockCreateMFInstance: vi.fn((_options: UserOptions) => ({
28
+ registerRemotes,
29
+ loadRemote,
30
+ preloadRemote,
31
+ })),
32
+ }
33
+ })
34
+
35
+ vi.mock('@module-federation/runtime', () => ({
36
+ createInstance: mockCreateMFInstance,
37
+ }))
38
+
39
+ vi.mock('@sanity/sdk/_internal', () => ({createLogger: mockCreateLogger}))
40
+
41
+ beforeEach(() => {
42
+ vi.clearAllMocks()
43
+ })
44
+
45
+ describe('createRemoteInstance', () => {
46
+ it('seeds empty remotes and preserves custom plugins', () => {
47
+ const customPlugin = {name: 'custom-plugin'}
48
+
49
+ createRemoteInstance({name: 'sanity-workbench', plugins: [customPlugin]})
50
+
51
+ expect(mockCreateLogger).toHaveBeenCalledWith('sanity-workbench')
52
+ expect(mockCreateMFInstance).toHaveBeenCalledWith({
53
+ name: 'sanity-workbench',
54
+ remotes: [],
55
+ plugins: [expect.objectContaining({name: 'sanity-logger'}), customPlugin],
56
+ })
57
+ })
58
+
59
+ it('seeds the underlying instance with the manifest of each remote entry', () => {
60
+ createRemoteInstance({
61
+ name: 'sanity-workbench',
62
+ remotes: [{name: 'studio-1', entry: 'https://example.com/apps/studio-1/'}],
63
+ })
64
+
65
+ expect(mockCreateMFInstance).toHaveBeenCalledWith({
66
+ name: 'sanity-workbench',
67
+ plugins: [expect.objectContaining({name: 'sanity-logger'})],
68
+ remotes: [{name: 'studio-1', entry: 'https://example.com/apps/studio-1/mf-manifest.json'}],
69
+ })
70
+ })
71
+
72
+ it('leaves a version-based remote untouched', () => {
73
+ const remote = {name: 'studio-1', version: '1.0.0'}
74
+
75
+ createRemoteInstance({name: 'sanity-workbench', remotes: [remote]})
76
+
77
+ expect(mockCreateMFInstance).toHaveBeenCalledWith({
78
+ name: 'sanity-workbench',
79
+ plugins: [expect.objectContaining({name: 'sanity-logger'})],
80
+ remotes: [remote],
81
+ })
82
+ })
83
+
84
+ it('registers a remote origin as its manifest, without forcing re-registration', () => {
85
+ const instance = createRemoteInstance({name: 'sanity-workbench'})
86
+
87
+ instance.registerRemotes([{name: 'studio-1', entry: 'https://example.com'}])
88
+
89
+ expect(mockRegisterRemotes).toHaveBeenCalledWith(
90
+ [{name: 'studio-1', entry: 'https://example.com/mf-manifest.json'}],
91
+ {force: false},
92
+ )
93
+ })
94
+
95
+ it('leaves an entry that already names the manifest untouched', () => {
96
+ const instance = createRemoteInstance({name: 'sanity-workbench'})
97
+ const remotes = [{name: 'studio-1', entry: 'https://example.com/mf-manifest.json'}]
98
+
99
+ instance.registerRemotes(remotes)
100
+
101
+ expect(mockRegisterRemotes).toHaveBeenCalledWith(remotes, {force: false})
102
+ })
103
+
104
+ it('resolves with the loaded module on success', async () => {
105
+ const module = {render: () => () => {}}
106
+ mockLoadRemote.mockResolvedValue(module)
107
+ const instance = createRemoteInstance({name: 'sanity-workbench'})
108
+
109
+ await expect(instance.loadRemote('studio-1/App')).resolves.toBe(module)
110
+ })
111
+
112
+ it('wraps a load failure with the module id and original cause', async () => {
113
+ const cause = new Error('network down')
114
+ mockLoadRemote.mockRejectedValue(cause)
115
+ const instance = createRemoteInstance({name: 'sanity-workbench'})
116
+
117
+ await expect(instance.loadRemote('studio-1/App')).rejects.toMatchObject({
118
+ message: 'Failed to load remote module "studio-1/App"',
119
+ cause,
120
+ })
121
+ })
122
+
123
+ it('preloads specific exposes, warming sync and async assets', () => {
124
+ const instance = createRemoteInstance({name: 'sanity-workbench'})
125
+
126
+ instance.preloadRemote('studio-1', ['views/feed/panel'])
127
+
128
+ expect(mockPreloadRemote).toHaveBeenCalledWith([
129
+ {
130
+ nameOrAlias: 'studio-1',
131
+ exposes: ['views/feed/panel'],
132
+ resourceCategory: 'all',
133
+ },
134
+ ])
135
+ })
136
+
137
+ it('preloads the whole remote when no exposes are given', () => {
138
+ const instance = createRemoteInstance({name: 'sanity-workbench'})
139
+
140
+ instance.preloadRemote('studio-1')
141
+
142
+ expect(mockPreloadRemote).toHaveBeenCalledWith([
143
+ {nameOrAlias: 'studio-1', exposes: undefined, resourceCategory: 'all'},
144
+ ])
145
+ })
146
+ })
147
+
148
+ describe('lifecycle logging', () => {
149
+ const hooks = [
150
+ 'beforeInit',
151
+ 'beforeRegisterRemote',
152
+ 'beforePreloadRemote',
153
+ 'beforeRequest',
154
+ 'afterResolve',
155
+ 'onLoad',
156
+ 'loadShare',
157
+ 'beforeLoadShare',
158
+ ] as const
159
+
160
+ it.each(hooks)('logs %s without the runtime instance', (hook) => {
161
+ createRemoteInstance({name: 'sanity-workbench'})
162
+ const plugin = mockCreateMFInstance.mock.calls[0][0]
163
+ .plugins![0] as ModuleFederationRuntimePlugin
164
+ // Deliberately partial args: only the logger's origin filtering is under test.
165
+ plugin[hook]?.({origin: 'noisy', id: hook} as never)
166
+ expect(onDebug).toHaveBeenCalledWith(`[Lifecycle] ${hook}`, {id: hook, internal: true})
167
+ })
168
+ })
@@ -0,0 +1,123 @@
1
+ import {createInstance as createMFInstance} from '@module-federation/runtime'
2
+ import {
3
+ type ModuleFederationRuntimePlugin,
4
+ type UserOptions,
5
+ } from '@module-federation/runtime/types'
6
+ import {createLogger} from '@sanity/sdk/_internal'
7
+
8
+ type MakeOptional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>
9
+
10
+ /**
11
+ * @public
12
+ */
13
+ export type FederationRemote = {
14
+ name: string
15
+ entry: string
16
+ }
17
+
18
+ const MANIFEST_FILE = 'mf-manifest.json'
19
+
20
+ function withManifest<T extends {entry: string}>(remote: T): T {
21
+ if (remote.entry.endsWith(`/${MANIFEST_FILE}`)) return remote
22
+ const base = remote.entry.endsWith('/') ? remote.entry : `${remote.entry}/`
23
+ return {...remote, entry: new URL(MANIFEST_FILE, base).href}
24
+ }
25
+
26
+ /**
27
+ * @public
28
+ */
29
+ export type CreateRemoteInstanceOptions = MakeOptional<
30
+ Pick<UserOptions, 'name' | 'remotes' | 'shared' | 'plugins'>,
31
+ 'remotes' | 'plugins'
32
+ >
33
+
34
+ /**
35
+ * @public
36
+ */
37
+ export interface RemoteInstance {
38
+ registerRemotes(remotes: FederationRemote[]): void
39
+ /** Loads and evaluates a remote expose. */
40
+ loadRemote<T>(id: string): Promise<T | null>
41
+ /** Preloads assets without evaluating them; omit exposes to warm the whole remote. */
42
+ preloadRemote(name: string, exposes?: string[]): Promise<void>
43
+ }
44
+
45
+ /**
46
+ * Creates a client for registering, loading, and preloading federated modules.
47
+ * @public
48
+ */
49
+ export function createRemoteInstance(options: CreateRemoteInstanceOptions): RemoteInstance {
50
+ const logger = createLogger(options.name)
51
+ const instance = createMFInstance({
52
+ ...options,
53
+ plugins: [log(logger.debug), ...(options.plugins ?? [])],
54
+ remotes:
55
+ options.remotes?.map((remote) => ('entry' in remote ? withManifest(remote) : remote)) ?? [],
56
+ })
57
+
58
+ return {
59
+ registerRemotes: (remotes) =>
60
+ instance.registerRemotes(remotes.map(withManifest), {force: false}),
61
+ loadRemote: async <T>(id: string) => {
62
+ let remoteModule: T | null
63
+
64
+ try {
65
+ remoteModule = await instance.loadRemote<T>(id)
66
+ } catch (error) {
67
+ throw new Error(`Failed to load remote module "${id}"`, {
68
+ cause: error,
69
+ })
70
+ }
71
+
72
+ return remoteModule
73
+ },
74
+ // Include async chunks so lazy code is warm before the remote loads.
75
+ preloadRemote: (name, exposes) =>
76
+ instance.preloadRemote([{nameOrAlias: name, exposes, resourceCategory: 'all'}]),
77
+ }
78
+ }
79
+
80
+ function log(
81
+ onDebug: (message: string, context?: Record<string, unknown>) => void,
82
+ ): ModuleFederationRuntimePlugin {
83
+ const logEvent = (eventName: string, args: object) => {
84
+ // `origin` logs the whole instance, which is noisy
85
+ const {origin: _origin, ...data} = args as Record<string, unknown>
86
+
87
+ onDebug(`[Lifecycle] ${eventName}`, {...data, internal: true})
88
+ }
89
+
90
+ return {
91
+ name: 'sanity-logger',
92
+ beforeInit(args) {
93
+ logEvent('beforeInit', args)
94
+ return args
95
+ },
96
+ beforeRegisterRemote(args) {
97
+ logEvent('beforeRegisterRemote', args)
98
+ return args
99
+ },
100
+ beforePreloadRemote(args) {
101
+ logEvent('beforePreloadRemote', args)
102
+ },
103
+ beforeRequest(args) {
104
+ logEvent('beforeRequest', args)
105
+ return args
106
+ },
107
+ afterResolve(args) {
108
+ logEvent('afterResolve', args)
109
+ return args
110
+ },
111
+ onLoad(args) {
112
+ logEvent('onLoad', args)
113
+ return args
114
+ },
115
+ loadShare(args) {
116
+ logEvent('loadShare', args)
117
+ },
118
+ beforeLoadShare(args) {
119
+ logEvent('beforeLoadShare', args)
120
+ return args
121
+ },
122
+ }
123
+ }
@@ -0,0 +1,39 @@
1
+ import * as React from 'react'
2
+
3
+ const MODULE_SLOT_KEY = Symbol.for('sanity.os.module')
4
+
5
+ type ModuleContext = React.Context<string | undefined>
6
+ type ModuleSlot = WeakMap<typeof React.createContext, ModuleContext>
7
+
8
+ /**
9
+ * Returns the React context that carries the current federation module id.
10
+ *
11
+ * @remarks
12
+ * This is the slot the CLI-generated wrapper populates with
13
+ * `renderOptions.moduleId`. The context lives in a per-React-copy slot on
14
+ * `globalThis` so the CLI wrapper and the SDK share the same context even
15
+ * across module copies. Each React copy gets its own context, since a context
16
+ * created by one copy is inert in another.
17
+ *
18
+ * The slot is keyed on `React.createContext` rather than the `React` namespace
19
+ * object: `import * as React` and `import React from 'react'` can yield
20
+ * different wrapper objects for the same React copy under bundler interop,
21
+ * whereas the `createContext` function is the same reference under both.
22
+ *
23
+ * The provider side lives in the CLI, which must not import the SDK:
24
+ * `packages/@sanity/workbench-cli/src/actions/build/render-remote.ts` in
25
+ * `sanity-io/cli` reconstructs this accessor (same symbol, same key) and
26
+ * wraps `App` in the context's `Provider`. Nothing in this repo exports it.
27
+ * @internal
28
+ */
29
+ export function getDashboardModuleContext(): ModuleContext {
30
+ const globals = globalThis as {[MODULE_SLOT_KEY]?: ModuleSlot}
31
+ const slot = (globals[MODULE_SLOT_KEY] ??= new WeakMap())
32
+ const key = React.createContext
33
+ let context = slot.get(key)
34
+ if (!context) {
35
+ context = React.createContext<string | undefined>(undefined)
36
+ slot.set(key, context)
37
+ }
38
+ return context
39
+ }
@@ -0,0 +1,27 @@
1
+ import {type SanityInstance, type StateSource} from '@sanity/sdk'
2
+ import {of} from 'rxjs'
3
+
4
+ import {createRemoteInstance, type RemoteInstance} from './createRemoteInstance'
5
+
6
+ const remoteClientStates = new WeakMap<SanityInstance, StateSource<RemoteInstance>>()
7
+
8
+ /** Returns the instance's remote client state, creating it on first use. @internal */
9
+ export function getRemoteClientState(instance: SanityInstance): StateSource<RemoteInstance> {
10
+ if (instance.isDisposed()) {
11
+ throw new Error('Cannot create a remote client for a disposed Sanity instance')
12
+ }
13
+
14
+ const current = remoteClientStates.get(instance)
15
+ if (current) return current
16
+
17
+ const client = createRemoteInstance({name: `sanity-remote-${instance.instanceId}`})
18
+ const state = {
19
+ getCurrent: () => client,
20
+ observable: of(client),
21
+ subscribe: () => () => {},
22
+ }
23
+
24
+ remoteClientStates.set(instance, state)
25
+ instance.onDispose(() => remoteClientStates.delete(instance))
26
+ return state
27
+ }