@sanity/sdk-react 3.0.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 (57) 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 +498 -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 +10 -115
  10. package/dist/index.js.map +1 -1
  11. package/dist/useStudioWorkspacesByProjectIdDataset-BZ_Ud887.js +314 -0
  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 +71 -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 +70 -20
  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 +4 -4
  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/src/utils/resolveOrgResources.test.ts +2 -2
  54. package/src/utils/resolveOrgResources.ts +2 -2
  55. package/dist/useStudioWorkspacesByProjectIdDataset-I4S3CuR5.js +0 -177
  56. package/dist/useStudioWorkspacesByProjectIdDataset-I4S3CuR5.js.map +0 -1
  57. package/src/context/dashboardToken.ts +0 -63
@@ -1,9 +1,11 @@
1
1
  import {AuthStateType} from '@sanity/sdk'
2
+ import {installMessageBus} from '@sanity/sdk/_internal'
2
3
  import {render, screen, waitFor} from '@testing-library/react'
3
4
  import React from 'react'
4
5
  import {type FallbackProps} from 'react-error-boundary'
5
6
  import {beforeEach, describe, expect, it, type MockInstance, vi} from 'vitest'
6
7
 
8
+ import {DashboardTokenRefreshProvider} from '../../context/DashboardTokenRefresh'
7
9
  import {ResourceProvider} from '../../context/ResourceProvider'
8
10
  import {useAuthState} from '../../hooks/auth/useAuthState'
9
11
  import {useLoginUrl} from '../../hooks/auth/useLoginUrl'
@@ -142,6 +144,38 @@ describe('AuthBoundary', () => {
142
144
  })
143
145
  })
144
146
 
147
+ it('does not redirect when a host bus is installed but the provider has not connected yet', () => {
148
+ // Workbench remotes start LOGGED_OUT (the host mints the token over the bus) and are not
149
+ // in an iframe, so the only thing standing between them and a login redirect is the
150
+ // dashboard check. AuthSwitch's effect runs before its parent provider's, so the check
151
+ // must not depend on the provider having connected first.
152
+ vi.mocked(useAuthState).mockReturnValue({
153
+ type: AuthStateType.LOGGED_OUT,
154
+ isDestroyingSession: false,
155
+ })
156
+ const originalLocation = window.location
157
+ const location = {href: 'http://remote.test/'}
158
+ Object.defineProperty(window, 'location', {value: location, writable: true})
159
+ vi.stubGlobal('__SANITY_APP_ID__', 'remote')
160
+ const globals = globalThis as {[key: symbol]: unknown}
161
+ const busKey = Symbol.for('sanity.os.bus')
162
+ installMessageBus({appId: 'dashboard'})
163
+ try {
164
+ render(
165
+ <ResourceProvider projectId="p" dataset="d" fallback={null}>
166
+ <DashboardTokenRefreshProvider>
167
+ <AuthBoundary projectIds={testProjectIds}>Protected Content</AuthBoundary>
168
+ </DashboardTokenRefreshProvider>
169
+ </ResourceProvider>,
170
+ )
171
+ expect(location.href).toBe('http://remote.test/')
172
+ } finally {
173
+ delete globals[busKey]
174
+ vi.unstubAllGlobals()
175
+ Object.defineProperty(window, 'location', {value: originalLocation, writable: true})
176
+ }
177
+ })
178
+
145
179
  it('renders the empty LoginCallback component when authState="logging-in"', () => {
146
180
  vi.mocked(useAuthState).mockReturnValue({
147
181
  type: AuthStateType.LOGGING_IN,
@@ -1,11 +1,10 @@
1
1
  import {CorsOriginError} from '@sanity/client'
2
2
  import {AuthStateType, getCorsErrorProjectId, isImportError} from '@sanity/sdk'
3
- import {isStudioConfig} from '@sanity/sdk/_internal'
3
+ import {isDashboardEnvironment, isStudioConfig} from '@sanity/sdk/_internal'
4
4
  import {useEffect, useMemo} from 'react'
5
5
  import {ErrorBoundary, type FallbackProps} from 'react-error-boundary'
6
6
 
7
7
  import {ComlinkTokenRefreshProvider} from '../../context/ComlinkTokenRefresh'
8
- import {isDashboardEnvironment} from '../../context/dashboardToken'
9
8
  import {DashboardTokenRefreshProvider} from '../../context/DashboardTokenRefresh'
10
9
  import {useAuthState} from '../../hooks/auth/useAuthState'
11
10
  import {useLoginUrl} from '../../hooks/auth/useLoginUrl'
@@ -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,36 +1,39 @@
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".
15
15
  *
16
- * When running as a federated remote inside the dashboard the OS owns the
17
- * session, so we subscribe to its `auth.token` stream and mirror each value into
16
+ * When running inside the dashboard the OS owns the session, so we subscribe
17
+ * to its `auth.token` stream and mirror each value into
18
18
  * the auth store — a token logs us in, `null` logs us out, and later OS
19
19
  * sign-in/out propagates automatically. When a request is rejected with a 401
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,23 +41,70 @@ 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
  }
49
56
 
50
57
  /**
51
- * Provides dashboard OS token refresh. No-op outside the dashboard, where the
52
- * app uses its normal auth flow.
53
- * @internal
58
+ * Authenticates the SDK with the Sanity Dashboard's session when the app runs
59
+ * inside the dashboard.
60
+ *
61
+ * The dashboard owns the session there: this provider subscribes to the token
62
+ * the dashboard issues, writes each new value into the SDK's auth store (where
63
+ * SDK hooks read it from), and asks the dashboard for a fresh token when a
64
+ * request fails with a 401. Outside the dashboard it renders children
65
+ * unchanged and the app's normal auth flow applies.
66
+ *
67
+ * @remarks
68
+ * `AuthBoundary` mounts this automatically, so most apps never need it
69
+ * directly. Mount it yourself only when your app runs inside the dashboard
70
+ * without `AuthBoundary` — that is, the app renders its own loading and error
71
+ * UI instead of the SDK's login flow — but still uses SDK hooks such as
72
+ * `useQuery`, which need the dashboard's token in the auth store to
73
+ * authenticate their requests.
74
+ *
75
+ * Mount it once, inside the provider that creates the Sanity instance whose
76
+ * store should receive the token.
77
+ *
78
+ * @example
79
+ * ```tsx
80
+ * import {ResourceProvider} from '@sanity/sdk-react'
81
+ * import {TokenRefreshProvider} from '@sanity/sdk-react/dashboard'
82
+ *
83
+ * function EmbeddedApp() {
84
+ * return (
85
+ * <ResourceProvider fallback={<Loading />}>
86
+ * <TokenRefreshProvider>
87
+ * <App />
88
+ * </TokenRefreshProvider>
89
+ * </ResourceProvider>
90
+ * )
91
+ * }
92
+ * ```
93
+ *
94
+ * @public
54
95
  */
55
96
  export const DashboardTokenRefreshProvider: React.FC<PropsWithChildren> = ({children}) => {
56
- if (isDashboardEnvironment()) {
57
- 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>
58
108
  }
59
109
 
60
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
+ })