@sanity/sdk-react 1.0.0 → 2.0.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sanity/sdk-react",
3
- "version": "1.0.0",
3
+ "version": "2.0.1",
4
4
  "private": false,
5
5
  "description": "Sanity SDK React toolkit for Content OS",
6
6
  "keywords": [
@@ -42,7 +42,7 @@
42
42
  "browserslist": "extends @sanity/browserslist-config",
43
43
  "prettier": "@sanity/prettier-config",
44
44
  "dependencies": {
45
- "@sanity/client": "^7.0.0",
45
+ "@sanity/client": "^7.2.1",
46
46
  "@sanity/message-protocol": "^0.12.0",
47
47
  "@sanity/types": "^3.83.0",
48
48
  "@types/lodash-es": "^4.17.12",
@@ -51,11 +51,11 @@
51
51
  "react-compiler-runtime": "19.1.0-rc.1",
52
52
  "react-error-boundary": "^5.0.0",
53
53
  "rxjs": "^7.8.2",
54
- "@sanity/sdk": "1.0.0"
54
+ "@sanity/sdk": "2.0.1"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@sanity/browserslist-config": "^1.0.5",
58
- "@sanity/comlink": "^3.0.2",
58
+ "@sanity/comlink": "^3.0.4",
59
59
  "@sanity/pkg-utils": "^7.2.2",
60
60
  "@sanity/prettier-config": "^1.0.3",
61
61
  "@testing-library/jest-dom": "^6.6.3",
@@ -74,11 +74,11 @@
74
74
  "typescript": "^5.8.3",
75
75
  "vite": "^6.3.4",
76
76
  "vitest": "^3.1.2",
77
- "@repo/config-test": "0.0.1",
78
- "@repo/package.bundle": "3.82.0",
79
77
  "@repo/package.config": "0.0.1",
80
78
  "@repo/tsconfig": "0.0.1",
81
- "@repo/config-eslint": "0.0.0"
79
+ "@repo/config-eslint": "0.0.0",
80
+ "@repo/config-test": "0.0.1",
81
+ "@repo/package.bundle": "3.82.0"
82
82
  },
83
83
  "peerDependencies": {
84
84
  "react": "^18.0.0 || ^19.0.0",
@@ -4,6 +4,7 @@
4
4
  export {AuthBoundary, type AuthBoundaryProps} from '../components/auth/AuthBoundary'
5
5
  export {SanityApp, type SanityAppProps} from '../components/SanityApp'
6
6
  export {SDKProvider, type SDKProviderProps} from '../components/SDKProvider'
7
+ export {ComlinkTokenRefreshProvider} from '../context/ComlinkTokenRefresh'
7
8
  export {ResourceProvider, type ResourceProviderProps} from '../context/ResourceProvider'
8
9
  export {useAuthState} from '../hooks/auth/useAuthState'
9
10
  export {useAuthToken} from '../hooks/auth/useAuthToken'
@@ -27,6 +28,7 @@ export {
27
28
  type WindowMessageHandler,
28
29
  } from '../hooks/comlink/useWindowConnection'
29
30
  export {useSanityInstance} from '../hooks/context/useSanityInstance'
31
+ export {useDashboardNavigate} from '../hooks/dashboard/useDashboardNavigate'
30
32
  export {useManageFavorite} from '../hooks/dashboard/useManageFavorite'
31
33
  export {
32
34
  type NavigateToStudioResult,
@@ -1,4 +1,4 @@
1
- import {AuthStateType} from '@sanity/sdk'
1
+ import {AuthStateType, type SanityConfig} from '@sanity/sdk'
2
2
  import {render, screen, waitFor} from '@testing-library/react'
3
3
  import React from 'react'
4
4
  import {type FallbackProps} from 'react-error-boundary'
@@ -8,6 +8,7 @@ import {ResourceProvider} from '../../context/ResourceProvider'
8
8
  import {useAuthState} from '../../hooks/auth/useAuthState'
9
9
  import {useLoginUrl} from '../../hooks/auth/useLoginUrl'
10
10
  import {useVerifyOrgProjects} from '../../hooks/auth/useVerifyOrgProjects'
11
+ import {useSanityInstance} from '../../hooks/context/useSanityInstance'
11
12
  import {AuthBoundary} from './AuthBoundary'
12
13
 
13
14
  // Mock hooks
@@ -22,6 +23,9 @@ vi.mock('../../hooks/auth/useHandleAuthCallback', () => ({
22
23
  vi.mock('../../hooks/auth/useLogOut', () => ({
23
24
  useLogOut: vi.fn(() => async () => {}),
24
25
  }))
26
+ vi.mock('../../hooks/context/useSanityInstance', () => ({
27
+ useSanityInstance: vi.fn(),
28
+ }))
25
29
 
26
30
  // Mock AuthError throwing scenario
27
31
  vi.mock('./AuthError', async (importOriginal) => {
@@ -105,8 +109,27 @@ describe('AuthBoundary', () => {
105
109
  const mockUseAuthState = vi.mocked(useAuthState)
106
110
  const mockUseLoginUrl = vi.mocked(useLoginUrl)
107
111
  const mockUseVerifyOrgProjects = vi.mocked(useVerifyOrgProjects)
112
+ const mockUseSanityInstance = vi.mocked(useSanityInstance)
108
113
  const testProjectIds = ['proj-test'] // Example project ID for tests
109
114
 
115
+ // Mock Sanity instance
116
+ const mockSanityInstance = {
117
+ instanceId: 'test-instance-id',
118
+ config: {
119
+ projectId: 'test-project',
120
+ dataset: 'test-dataset',
121
+ },
122
+ isDisposed: () => false,
123
+ dispose: () => {},
124
+ onDispose: () => () => {},
125
+ getParent: () => undefined,
126
+ createChild: (config: SanityConfig) => ({
127
+ ...mockSanityInstance,
128
+ config: {...mockSanityInstance.config, ...config},
129
+ }),
130
+ match: () => undefined,
131
+ }
132
+
110
133
  beforeEach(() => {
111
134
  vi.clearAllMocks()
112
135
  consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
@@ -116,6 +139,8 @@ describe('AuthBoundary', () => {
116
139
  mockUseLoginUrl.mockReturnValue('http://example.com/login')
117
140
  // Default mock for useVerifyOrgProjects - returns null (no error)
118
141
  mockUseVerifyOrgProjects.mockImplementation(() => null)
142
+ // Mock useSanityInstance to return our mock instance
143
+ mockUseSanityInstance.mockReturnValue(mockSanityInstance)
119
144
  })
120
145
 
121
146
  afterEach(() => {
@@ -145,9 +170,7 @@ describe('AuthBoundary', () => {
145
170
  isExchangingToken: false,
146
171
  })
147
172
  const {container} = render(
148
- <ResourceProvider fallback={null}>
149
- <AuthBoundary projectIds={testProjectIds}>Protected Content</AuthBoundary>
150
- </ResourceProvider>,
173
+ <AuthBoundary projectIds={testProjectIds}>Protected Content</AuthBoundary>,
151
174
  )
152
175
 
153
176
  // The callback screen renders null check that it renders nothing
@@ -161,11 +184,7 @@ describe('AuthBoundary', () => {
161
184
  currentUser: null,
162
185
  token: 'exampleToken',
163
186
  })
164
- render(
165
- <ResourceProvider fallback={null}>
166
- <AuthBoundary projectIds={testProjectIds}>Protected Content</AuthBoundary>
167
- </ResourceProvider>,
168
- )
187
+ render(<AuthBoundary projectIds={testProjectIds}>Protected Content</AuthBoundary>)
169
188
 
170
189
  expect(screen.getByText('Protected Content')).toBeInTheDocument()
171
190
  })
@@ -175,11 +194,7 @@ describe('AuthBoundary', () => {
175
194
  type: AuthStateType.ERROR,
176
195
  error: new Error('test error'),
177
196
  })
178
- render(
179
- <ResourceProvider fallback={null}>
180
- <AuthBoundary projectIds={testProjectIds}>Protected Content</AuthBoundary>
181
- </ResourceProvider>,
182
- )
197
+ render(<AuthBoundary projectIds={testProjectIds}>Protected Content</AuthBoundary>)
183
198
 
184
199
  // The AuthBoundary should throw an AuthError internally
185
200
  // and then display the LoginError component as the fallback.
@@ -192,11 +207,7 @@ describe('AuthBoundary', () => {
192
207
  })
193
208
 
194
209
  it('renders children when logged in and org verification passes', () => {
195
- render(
196
- <ResourceProvider fallback={null}>
197
- <AuthBoundary projectIds={testProjectIds}>Protected Content</AuthBoundary>
198
- </ResourceProvider>,
199
- )
210
+ render(<AuthBoundary projectIds={testProjectIds}>Protected Content</AuthBoundary>)
200
211
  expect(screen.getByText('Protected Content')).toBeInTheDocument()
201
212
  })
202
213
 
@@ -2,6 +2,7 @@ import {AuthStateType} from '@sanity/sdk'
2
2
  import {useEffect, useMemo} from 'react'
3
3
  import {ErrorBoundary, type FallbackProps} from 'react-error-boundary'
4
4
 
5
+ import {ComlinkTokenRefreshProvider} from '../../context/ComlinkTokenRefresh'
5
6
  import {useAuthState} from '../../hooks/auth/useAuthState'
6
7
  import {useLoginUrl} from '../../hooks/auth/useLoginUrl'
7
8
  import {useVerifyOrgProjects} from '../../hooks/auth/useVerifyOrgProjects'
@@ -111,9 +112,11 @@ export function AuthBoundary({
111
112
  }, [LoginErrorComponent])
112
113
 
113
114
  return (
114
- <ErrorBoundary FallbackComponent={FallbackComponent}>
115
- <AuthSwitch {...props} />
116
- </ErrorBoundary>
115
+ <ComlinkTokenRefreshProvider>
116
+ <ErrorBoundary FallbackComponent={FallbackComponent}>
117
+ <AuthSwitch {...props} />
118
+ </ErrorBoundary>
119
+ </ComlinkTokenRefreshProvider>
117
120
  )
118
121
  }
119
122
 
@@ -19,7 +19,15 @@ export type LoginErrorProps = FallbackProps
19
19
  * @alpha
20
20
  */
21
21
  export function LoginError({error, resetErrorBoundary}: LoginErrorProps): React.ReactNode {
22
- if (!(error instanceof AuthError || error instanceof ConfigurationError)) throw error
22
+ if (
23
+ !(
24
+ error instanceof AuthError ||
25
+ error instanceof ConfigurationError ||
26
+ error instanceof ClientError
27
+ )
28
+ )
29
+ throw error
30
+
23
31
  const logout = useLogOut()
24
32
  const authState = useAuthState()
25
33
 
@@ -33,11 +41,11 @@ export function LoginError({error, resetErrorBoundary}: LoginErrorProps): React.
33
41
  }, [logout, resetErrorBoundary])
34
42
 
35
43
  useEffect(() => {
36
- if (authState.type === AuthStateType.ERROR && authState.error instanceof ClientError) {
37
- if (authState.error.statusCode === 401) {
44
+ if (error instanceof ClientError) {
45
+ if (error.statusCode === 401) {
38
46
  handleRetry()
39
- } else if (authState.error.statusCode === 404) {
40
- const errorMessage = authState.error.response.body.message || ''
47
+ } else if (error.statusCode === 404) {
48
+ const errorMessage = error.response.body.message || ''
41
49
  if (errorMessage.startsWith('Session with sid') && errorMessage.endsWith('not found')) {
42
50
  setAuthErrorMessage('The session ID is invalid or expired.')
43
51
  } else {
@@ -0,0 +1,221 @@
1
+ import {SDK_CHANNEL_NAME, SDK_NODE_NAME} from '@sanity/message-protocol'
2
+ import {AuthStateType, getIsInDashboardState, setAuthToken} from '@sanity/sdk'
3
+ import {act, render} from '@testing-library/react'
4
+ import {afterEach, beforeEach, describe, expect, it, type Mock, vi} from 'vitest'
5
+
6
+ import {useAuthState} from '../hooks/auth/useAuthState'
7
+ import {useWindowConnection} from '../hooks/comlink/useWindowConnection'
8
+ import {useSanityInstance} from '../hooks/context/useSanityInstance'
9
+ import {ComlinkTokenRefreshProvider} from './ComlinkTokenRefresh'
10
+
11
+ // Mocks
12
+ vi.mock('@sanity/sdk', async () => {
13
+ const actual = await vi.importActual('@sanity/sdk')
14
+ return {
15
+ ...actual,
16
+ getIsInDashboardState: vi.fn(() => ({getCurrent: vi.fn()})),
17
+ setAuthToken: vi.fn(),
18
+ }
19
+ })
20
+
21
+ vi.mock('../hooks/auth/useAuthState', () => ({
22
+ useAuthState: vi.fn(),
23
+ }))
24
+
25
+ vi.mock('../hooks/comlink/useWindowConnection', () => ({
26
+ useWindowConnection: vi.fn(),
27
+ }))
28
+
29
+ vi.mock('../hooks/context/useSanityInstance', () => ({
30
+ useSanityInstance: vi.fn(),
31
+ }))
32
+
33
+ // Use simpler mock typings
34
+ const mockGetIsInDashboardState = getIsInDashboardState as Mock
35
+ const mockSetAuthToken = setAuthToken as Mock
36
+ const mockUseAuthState = useAuthState as Mock
37
+ const mockUseWindowConnection = useWindowConnection as Mock
38
+ const mockUseSanityInstance = useSanityInstance as Mock
39
+
40
+ const mockFetch = vi.fn()
41
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
42
+ const mockSanityInstance: any = {projectId: 'test', dataset: 'test'}
43
+
44
+ describe('ComlinkTokenRefresh', () => {
45
+ beforeEach(() => {
46
+ vi.useFakeTimers()
47
+ mockGetIsInDashboardState.mockReturnValue({getCurrent: vi.fn(() => false)})
48
+ mockUseAuthState.mockReturnValue({type: AuthStateType.LOGGED_IN})
49
+ mockUseWindowConnection.mockReturnValue({fetch: mockFetch})
50
+ mockUseSanityInstance.mockReturnValue(mockSanityInstance)
51
+ })
52
+
53
+ afterEach(() => {
54
+ vi.clearAllMocks()
55
+ vi.useRealTimers()
56
+ })
57
+
58
+ describe('ComlinkTokenRefreshProvider', () => {
59
+ describe('when not in dashboard', () => {
60
+ beforeEach(() => {
61
+ mockGetIsInDashboardState.mockReturnValue({getCurrent: () => false})
62
+ })
63
+
64
+ it('should not request new token on 401 if not in dashboard', async () => {
65
+ mockUseAuthState.mockReturnValue({type: AuthStateType.LOGGED_IN})
66
+ const {rerender} = render(
67
+ <ComlinkTokenRefreshProvider>
68
+ <div>Test</div>
69
+ </ComlinkTokenRefreshProvider>,
70
+ )
71
+
72
+ mockUseAuthState.mockReturnValue({
73
+ type: AuthStateType.ERROR,
74
+ error: {statusCode: 401, message: 'Unauthorized'},
75
+ })
76
+ act(() => {
77
+ rerender(
78
+ <ComlinkTokenRefreshProvider>
79
+ <div>Test</div>
80
+ </ComlinkTokenRefreshProvider>,
81
+ )
82
+ })
83
+
84
+ await act(async () => {
85
+ await vi.advanceTimersByTimeAsync(100)
86
+ })
87
+ expect(mockFetch).not.toHaveBeenCalled()
88
+ })
89
+ })
90
+
91
+ describe('when in dashboard', () => {
92
+ beforeEach(() => {
93
+ mockGetIsInDashboardState.mockReturnValue({getCurrent: () => true})
94
+ })
95
+
96
+ it('should initialize useWindowConnection with correct parameters', () => {
97
+ render(
98
+ <ComlinkTokenRefreshProvider>
99
+ <div>Test</div>
100
+ </ComlinkTokenRefreshProvider>,
101
+ )
102
+
103
+ expect(mockUseWindowConnection).toHaveBeenCalledWith(
104
+ expect.objectContaining({
105
+ name: SDK_NODE_NAME,
106
+ connectTo: SDK_CHANNEL_NAME,
107
+ }),
108
+ )
109
+ })
110
+
111
+ it('should handle received token', async () => {
112
+ mockUseAuthState.mockReturnValue({
113
+ type: AuthStateType.ERROR,
114
+ error: {statusCode: 401, message: 'Unauthorized'},
115
+ })
116
+ mockFetch.mockResolvedValueOnce({token: 'new-token'})
117
+
118
+ render(
119
+ <ComlinkTokenRefreshProvider>
120
+ <div>Test</div>
121
+ </ComlinkTokenRefreshProvider>,
122
+ )
123
+
124
+ await act(async () => {
125
+ await vi.advanceTimersByTimeAsync(100)
126
+ })
127
+
128
+ expect(mockSetAuthToken).toHaveBeenCalledWith(mockSanityInstance, 'new-token')
129
+ expect(mockFetch).toHaveBeenCalledTimes(1)
130
+ })
131
+
132
+ it('should not set auth token if received token is null', async () => {
133
+ mockUseAuthState.mockReturnValue({
134
+ type: AuthStateType.ERROR,
135
+ error: {statusCode: 401, message: 'Unauthorized'},
136
+ })
137
+ mockFetch.mockResolvedValueOnce({token: null})
138
+
139
+ render(
140
+ <ComlinkTokenRefreshProvider>
141
+ <div>Test</div>
142
+ </ComlinkTokenRefreshProvider>,
143
+ )
144
+
145
+ await act(async () => {
146
+ await vi.advanceTimersByTimeAsync(100)
147
+ })
148
+
149
+ expect(mockSetAuthToken).not.toHaveBeenCalled()
150
+ })
151
+
152
+ it('should handle fetch errors gracefully', async () => {
153
+ mockUseAuthState.mockReturnValue({
154
+ type: AuthStateType.ERROR,
155
+ error: {statusCode: 401, message: 'Unauthorized'},
156
+ })
157
+ mockFetch.mockRejectedValueOnce(new Error('Fetch failed'))
158
+
159
+ render(
160
+ <ComlinkTokenRefreshProvider>
161
+ <div>Test</div>
162
+ </ComlinkTokenRefreshProvider>,
163
+ )
164
+
165
+ await act(async () => {
166
+ await vi.advanceTimersByTimeAsync(100)
167
+ })
168
+
169
+ expect(mockFetch).toHaveBeenCalledTimes(1)
170
+ })
171
+
172
+ describe('Automatic token refresh', () => {
173
+ it('should not request new token for non-401 errors', async () => {
174
+ mockUseAuthState.mockReturnValue({type: AuthStateType.LOGGED_IN})
175
+ const {rerender} = render(
176
+ <ComlinkTokenRefreshProvider>
177
+ <div>Test</div>
178
+ </ComlinkTokenRefreshProvider>,
179
+ )
180
+
181
+ mockUseAuthState.mockReturnValue({
182
+ type: AuthStateType.ERROR,
183
+ error: {statusCode: 500, message: 'Server Error'},
184
+ })
185
+ act(() => {
186
+ rerender(
187
+ <ComlinkTokenRefreshProvider>
188
+ <div>Test</div>
189
+ </ComlinkTokenRefreshProvider>,
190
+ )
191
+ })
192
+
193
+ await act(async () => {
194
+ await vi.advanceTimersByTimeAsync(100)
195
+ })
196
+ expect(mockFetch).not.toHaveBeenCalled()
197
+ })
198
+
199
+ it('should request new token on LOGGED_OUT state', async () => {
200
+ mockUseAuthState.mockReturnValue({type: AuthStateType.LOGGED_IN})
201
+ const {rerender} = render(
202
+ <ComlinkTokenRefreshProvider>
203
+ <div>Test</div>
204
+ </ComlinkTokenRefreshProvider>,
205
+ )
206
+
207
+ mockUseAuthState.mockReturnValue({type: AuthStateType.LOGGED_OUT})
208
+ act(() => {
209
+ rerender(
210
+ <ComlinkTokenRefreshProvider>
211
+ <div>Test</div>
212
+ </ComlinkTokenRefreshProvider>,
213
+ )
214
+ })
215
+
216
+ expect(mockFetch).toHaveBeenCalledWith('dashboard/v1/auth/tokens/create')
217
+ })
218
+ })
219
+ })
220
+ })
221
+ })
@@ -0,0 +1,125 @@
1
+ import {type ClientError} from '@sanity/client'
2
+ import {SDK_CHANNEL_NAME, SDK_NODE_NAME} from '@sanity/message-protocol'
3
+ import {
4
+ AuthStateType,
5
+ type FrameMessage,
6
+ getIsInDashboardState,
7
+ type NewTokenResponseMessage,
8
+ type RequestNewTokenMessage,
9
+ setAuthToken,
10
+ type WindowMessage,
11
+ } from '@sanity/sdk'
12
+ import React, {type PropsWithChildren, useCallback, useEffect, useMemo, useRef} from 'react'
13
+
14
+ import {useAuthState} from '../hooks/auth/useAuthState'
15
+ import {useWindowConnection} from '../hooks/comlink/useWindowConnection'
16
+ import {useSanityInstance} from '../hooks/context/useSanityInstance'
17
+
18
+ // Define specific message types extending the base types for clarity
19
+ type SdkParentComlinkMessage = NewTokenResponseMessage | WindowMessage // Messages received by SDK
20
+ type SdkChildComlinkMessage = RequestNewTokenMessage | FrameMessage // Messages sent by SDK
21
+
22
+ const DEFAULT_RESPONSE_TIMEOUT = 10000 // 10 seconds
23
+
24
+ /**
25
+ * Component that handles token refresh in dashboard mode
26
+ */
27
+ function DashboardTokenRefresh({children}: PropsWithChildren) {
28
+ const instance = useSanityInstance()
29
+ const isTokenRefreshInProgress = useRef(false)
30
+ const timeoutRef = useRef<NodeJS.Timeout | null>(null)
31
+ const processed401ErrorRef = useRef<unknown | null>(null)
32
+ const authState = useAuthState()
33
+
34
+ const clearRefreshTimeout = useCallback(() => {
35
+ if (timeoutRef.current) {
36
+ clearTimeout(timeoutRef.current)
37
+ timeoutRef.current = null
38
+ }
39
+ }, [])
40
+
41
+ const windowConnection = useWindowConnection<SdkParentComlinkMessage, SdkChildComlinkMessage>({
42
+ name: SDK_NODE_NAME,
43
+ connectTo: SDK_CHANNEL_NAME,
44
+ })
45
+
46
+ const requestNewToken = useCallback(async () => {
47
+ if (isTokenRefreshInProgress.current) {
48
+ return
49
+ }
50
+
51
+ isTokenRefreshInProgress.current = true
52
+ clearRefreshTimeout()
53
+
54
+ timeoutRef.current = setTimeout(() => {
55
+ if (isTokenRefreshInProgress.current) {
56
+ isTokenRefreshInProgress.current = false
57
+ }
58
+ timeoutRef.current = null
59
+ }, DEFAULT_RESPONSE_TIMEOUT)
60
+
61
+ try {
62
+ const res = await windowConnection.fetch<{token: string | null; error?: string}>(
63
+ 'dashboard/v1/auth/tokens/create',
64
+ )
65
+ clearRefreshTimeout()
66
+
67
+ if (res.token) {
68
+ setAuthToken(instance, res.token)
69
+ }
70
+ isTokenRefreshInProgress.current = false
71
+ } catch {
72
+ isTokenRefreshInProgress.current = false
73
+ clearRefreshTimeout()
74
+ }
75
+ }, [windowConnection, clearRefreshTimeout, instance])
76
+
77
+ useEffect(() => {
78
+ return () => {
79
+ clearRefreshTimeout()
80
+ }
81
+ }, [clearRefreshTimeout])
82
+
83
+ useEffect(() => {
84
+ const has401Error =
85
+ authState.type === AuthStateType.ERROR &&
86
+ authState.error &&
87
+ (authState.error as ClientError)?.statusCode === 401 &&
88
+ !isTokenRefreshInProgress.current &&
89
+ processed401ErrorRef.current !== authState.error
90
+
91
+ const isLoggedOut =
92
+ authState.type === AuthStateType.LOGGED_OUT && !isTokenRefreshInProgress.current
93
+
94
+ if (has401Error || isLoggedOut) {
95
+ processed401ErrorRef.current =
96
+ authState.type === AuthStateType.ERROR ? authState.error : undefined
97
+ requestNewToken()
98
+ } else if (
99
+ authState.type !== AuthStateType.ERROR ||
100
+ processed401ErrorRef.current !==
101
+ (authState.type === AuthStateType.ERROR ? authState.error : undefined)
102
+ ) {
103
+ processed401ErrorRef.current = null
104
+ }
105
+ }, [authState, requestNewToken])
106
+
107
+ return children
108
+ }
109
+
110
+ /**
111
+ * This provider is used to provide the Comlink token refresh feature.
112
+ * It is used to automatically request a new token on 401 error if enabled.
113
+ * @public
114
+ */
115
+ export const ComlinkTokenRefreshProvider: React.FC<PropsWithChildren> = ({children}) => {
116
+ const instance = useSanityInstance()
117
+ const isInDashboard = useMemo(() => getIsInDashboardState(instance).getCurrent(), [instance])
118
+
119
+ if (isInDashboard) {
120
+ return <DashboardTokenRefresh>{children}</DashboardTokenRefresh>
121
+ }
122
+
123
+ // If we're not in the dashboard, we don't need to do anything
124
+ return children
125
+ }
@@ -0,0 +1,44 @@
1
+ import {type PathChangeMessage} from '@sanity/message-protocol'
2
+ import {renderHook} from '@testing-library/react'
3
+ import {beforeEach, describe, expect, it, vi} from 'vitest'
4
+
5
+ import {useDashboardNavigate} from './useDashboardNavigate'
6
+
7
+ const mockOnMessage = vi.fn()
8
+ let mockMessageHandler: ((data: PathChangeMessage['data']) => void) | undefined
9
+
10
+ vi.mock('../comlink/useWindowConnection', () => {
11
+ return {
12
+ useWindowConnection: ({
13
+ onMessage,
14
+ }: {
15
+ onMessage: Record<string, (data: PathChangeMessage['data']) => void>
16
+ }) => {
17
+ mockMessageHandler = onMessage['dashboard/v1/history/change-path']
18
+ return {
19
+ onMessage: mockOnMessage,
20
+ }
21
+ },
22
+ }
23
+ })
24
+
25
+ describe('useDashboardNavigate', () => {
26
+ const mockNavigateFn = vi.fn()
27
+
28
+ beforeEach(() => {
29
+ vi.resetAllMocks()
30
+ mockMessageHandler = undefined
31
+ })
32
+
33
+ it('calls navigate function with correct data when message is received', () => {
34
+ renderHook(() => useDashboardNavigate(mockNavigateFn))
35
+
36
+ const mockNavigationData = {
37
+ path: '/test-path',
38
+ type: 'push' as const,
39
+ }
40
+ mockMessageHandler?.(mockNavigationData)
41
+
42
+ expect(mockNavigateFn).toHaveBeenCalledWith(mockNavigationData)
43
+ })
44
+ })
@@ -0,0 +1,60 @@
1
+ import {type PathChangeMessage, SDK_CHANNEL_NAME, SDK_NODE_NAME} from '@sanity/message-protocol'
2
+
3
+ import {useWindowConnection} from '../comlink/useWindowConnection'
4
+
5
+ /**
6
+ * @public
7
+ *
8
+ * A helper hook designed to be injected into routing components for apps within the Dashboard.
9
+ * While the Dashboard can usually handle navigation, there are special cases when you
10
+ * are already within a target app, and need to navigate to another route inside of that app.
11
+ *
12
+ * For example, your user might "favorite" a document inside of your application.
13
+ * If they click on the Dashboard favorites item in the sidebar, and are already within your application,
14
+ * there needs to be some way for the dashboard to signal to your application to reroute to where that document was favorited.
15
+ *
16
+ * This hook is intended to receive those messages, and takes a function to route to the correct path.
17
+ *
18
+ * @param navigateFn - Function to handle navigation; should accept:
19
+ * - `path`: a string, which will be a relative path (for example, 'my-route')
20
+ * - `type`: 'push', 'replace', or 'pop', which will be the type of navigation to perform
21
+ *
22
+ * @example
23
+ * ```tsx
24
+ * import {useDashboardNavigate} from '@sanity/sdk-react'
25
+ * import {BrowserRouter, useNavigate} from 'react-router'
26
+ * import {Suspense} from 'react'
27
+ *
28
+ * function DashboardNavigationHandler() {
29
+ * const navigate = useNavigate()
30
+ * useDashboardNavigate(({path, type}) => {
31
+ * navigate(path, {replace: type === 'replace'})
32
+ * })
33
+ * return null
34
+ * }
35
+ *
36
+ * // Wrap the component with Suspense since the hook may suspend
37
+ * function MyApp() {
38
+ * return (
39
+ * <BrowserRouter>
40
+ * <Suspense>
41
+ * <DashboardNavigationHandler />
42
+ * </Suspense>
43
+ * </BrowserRouter>
44
+ * )
45
+ * }
46
+ * ```
47
+ */
48
+ export function useDashboardNavigate(
49
+ navigateFn: (options: PathChangeMessage['data']) => void,
50
+ ): void {
51
+ useWindowConnection<PathChangeMessage, never>({
52
+ name: SDK_NODE_NAME,
53
+ connectTo: SDK_CHANNEL_NAME,
54
+ onMessage: {
55
+ 'dashboard/v1/history/change-path': (data: PathChangeMessage['data']) => {
56
+ navigateFn(data)
57
+ },
58
+ },
59
+ })
60
+ }