@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.
- package/dist/_exports/dashboard-internal.d.ts +2 -0
- package/dist/_exports/dashboard-internal.js +2 -0
- package/dist/_exports/dashboard.d.ts +498 -7
- package/dist/_exports/dashboard.d.ts.map +1 -1
- package/dist/_exports/dashboard.js +623 -2
- package/dist/_exports/dashboard.js.map +1 -1
- package/dist/index.d.ts +90 -87
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -115
- package/dist/index.js.map +1 -1
- package/dist/useStudioWorkspacesByProjectIdDataset-BZ_Ud887.js +314 -0
- package/dist/useStudioWorkspacesByProjectIdDataset-BZ_Ud887.js.map +1 -0
- package/package.json +34 -32
- package/src/_exports/dashboard-internal.ts +1 -0
- package/src/_exports/dashboard.test-d.ts +64 -0
- package/src/_exports/dashboard.ts +71 -0
- package/src/_exports/index.ts +1 -1
- package/src/components/auth/AuthBoundary.test.tsx +34 -0
- package/src/components/auth/AuthBoundary.tsx +1 -2
- package/src/context/DashboardTokenRefresh.test.tsx +124 -22
- package/src/context/DashboardTokenRefresh.tsx +70 -20
- package/src/dashboard/createRemoteInstance.test.ts +168 -0
- package/src/dashboard/createRemoteInstance.ts +123 -0
- package/src/dashboard/module.ts +39 -0
- package/src/dashboard/remoteClientState.ts +27 -0
- package/src/dashboard/urlFor.test.ts +246 -0
- package/src/dashboard/urlFor.ts +462 -0
- package/src/hooks/applications/useApplication.test-d.ts +1 -1
- package/src/hooks/dashboard/useApplication.test.tsx +59 -0
- package/src/hooks/dashboard/useApplication.ts +22 -0
- package/src/hooks/dashboard/useApplicationConfig.test.tsx +106 -0
- package/src/hooks/dashboard/useApplicationConfig.ts +45 -0
- package/src/hooks/dashboard/useApplicationConfigs.test.tsx +89 -0
- package/src/hooks/dashboard/useApplicationConfigs.ts +26 -0
- package/src/hooks/dashboard/useApplicationForegroundId.test.tsx +54 -0
- package/src/hooks/dashboard/useApplicationForegroundId.ts +22 -0
- package/src/hooks/dashboard/useApplications.test.tsx +249 -0
- package/src/hooks/dashboard/useApplications.ts +128 -0
- package/src/hooks/dashboard/useEmit.test.tsx +124 -0
- package/src/hooks/dashboard/useEmit.ts +78 -0
- package/src/hooks/dashboard/useNavigate.ts +1 -3
- package/src/hooks/dashboard/useRemoteClient.test.tsx +88 -0
- package/src/hooks/dashboard/useRemoteClient.ts +10 -0
- package/src/hooks/dashboard/useTopic.test.tsx +200 -0
- package/src/hooks/dashboard/useTopic.ts +29 -0
- package/src/hooks/dashboard/useUpdateFavorite.test.tsx +8 -1
- package/src/hooks/document/useApplyDocumentActions.ts +3 -4
- package/src/hooks/document/useCreateDocument.ts +4 -4
- package/src/hooks/document/useDocument.ts +11 -6
- package/src/hooks/document/useEditDocument.ts +5 -5
- package/src/hooks/projection/useDocumentProjection.ts +6 -7
- package/src/hooks/query/useQuery.ts +3 -4
- package/src/utils/resolveOrgResources.test.ts +2 -2
- package/src/utils/resolveOrgResources.ts +2 -2
- package/dist/useStudioWorkspacesByProjectIdDataset-I4S3CuR5.js +0 -177
- package/dist/useStudioWorkspacesByProjectIdDataset-I4S3CuR5.js.map +0 -1
- 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('
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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('
|
|
85
|
-
|
|
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(
|
|
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
|
|
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
|
|
17
|
-
*
|
|
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({
|
|
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
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
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
|
-
|
|
57
|
-
|
|
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
|
+
})
|