@sanity/sdk-react 2.7.0 → 2.9.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/index.d.ts +144 -25
- package/dist/index.js +248 -139
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
- package/src/_exports/sdk-react.ts +1 -0
- package/src/components/SanityApp.tsx +1 -0
- package/src/components/auth/AuthBoundary.test.tsx +3 -0
- package/src/components/auth/LoginError.test.tsx +5 -0
- package/src/components/auth/LoginError.tsx +22 -1
- package/src/context/ResourceProvider.test.tsx +7 -1
- package/src/context/ResourceProvider.tsx +6 -0
- package/src/context/SDKStudioContext.ts +6 -0
- package/src/hooks/dashboard/useDispatchIntent.test.ts +2 -0
- package/src/hooks/dashboard/useWindowTitle.test.ts +213 -0
- package/src/hooks/dashboard/useWindowTitle.ts +112 -0
- package/src/hooks/document/useApplyDocumentActions.test.ts +113 -10
- package/src/hooks/document/useApplyDocumentActions.ts +99 -3
- package/src/hooks/document/useDocument.ts +22 -6
- package/src/hooks/document/useDocumentEvent.test.tsx +3 -3
- package/src/hooks/document/useDocumentEvent.ts +10 -3
- package/src/hooks/document/useDocumentPermissions.test.tsx +86 -2
- package/src/hooks/document/useDocumentPermissions.ts +22 -0
- package/src/hooks/document/useDocumentSyncStatus.test.ts +13 -2
- package/src/hooks/document/useDocumentSyncStatus.ts +14 -5
- package/src/hooks/document/useEditDocument.ts +34 -8
- package/src/hooks/documents/useDocuments.ts +2 -0
- package/src/hooks/helpers/useNormalizedSourceOptions.ts +50 -28
- package/src/hooks/helpers/useTrackHookUsage.ts +37 -0
- package/src/hooks/paginatedDocuments/usePaginatedDocuments.ts +2 -0
- package/src/hooks/presence/usePresence.ts +2 -0
- package/src/hooks/preview/useDocumentPreview.test.tsx +84 -193
- package/src/hooks/preview/useDocumentPreview.tsx +39 -55
- package/src/hooks/projection/useDocumentProjection.ts +2 -0
- package/src/hooks/query/useQuery.ts +2 -0
- package/src/hooks/releases/useActiveReleases.ts +32 -13
- package/src/hooks/releases/usePerspective.ts +26 -14
- package/src/hooks/users/useUser.ts +2 -0
- package/src/hooks/users/useUsers.ts +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sanity/sdk-react",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.9.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Sanity SDK React toolkit for Content OS",
|
|
6
6
|
"keywords": [
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
"react-compiler-runtime": "19.1.0-rc.2",
|
|
53
53
|
"react-error-boundary": "^5.0.0",
|
|
54
54
|
"rxjs": "^7.8.2",
|
|
55
|
-
"@sanity/sdk": "2.
|
|
55
|
+
"@sanity/sdk": "2.9.0"
|
|
56
56
|
},
|
|
57
57
|
"devDependencies": {
|
|
58
58
|
"@sanity/browserslist-config": "^1.0.5",
|
|
@@ -75,13 +75,13 @@
|
|
|
75
75
|
"react-dom": "^19.2.1",
|
|
76
76
|
"rollup-plugin-visualizer": "^5.14.0",
|
|
77
77
|
"typescript": "^5.8.3",
|
|
78
|
-
"vite": "^
|
|
78
|
+
"vite": "^7.0.0",
|
|
79
79
|
"vitest": "^3.2.4",
|
|
80
|
+
"@repo/tsconfig": "0.0.1",
|
|
80
81
|
"@repo/package.config": "0.0.1",
|
|
81
|
-
"@repo/config-eslint": "0.0.0",
|
|
82
82
|
"@repo/config-test": "0.0.1",
|
|
83
83
|
"@repo/package.bundle": "3.82.0",
|
|
84
|
-
"@repo/
|
|
84
|
+
"@repo/config-eslint": "0.0.0"
|
|
85
85
|
},
|
|
86
86
|
"peerDependencies": {
|
|
87
87
|
"react": "^18.0.0 || ^19.0.0",
|
|
@@ -50,6 +50,7 @@ export {
|
|
|
50
50
|
} from '../hooks/dashboard/useNavigateToStudioDocument'
|
|
51
51
|
export {useRecordDocumentHistoryEvent} from '../hooks/dashboard/useRecordDocumentHistoryEvent'
|
|
52
52
|
export {useStudioWorkspacesByProjectIdDataset} from '../hooks/dashboard/useStudioWorkspacesByProjectIdDataset'
|
|
53
|
+
export {useWindowTitle} from '../hooks/dashboard/useWindowTitle'
|
|
53
54
|
export {useDatasets} from '../hooks/datasets/useDatasets'
|
|
54
55
|
export {useApplyDocumentActions} from '../hooks/document/useApplyDocumentActions'
|
|
55
56
|
export {useDocument} from '../hooks/document/useDocument'
|
|
@@ -37,6 +37,7 @@ function deriveConfigFromWorkspace(workspace: StudioWorkspaceHandle): SanityConf
|
|
|
37
37
|
projectId: workspace.projectId,
|
|
38
38
|
dataset: workspace.dataset,
|
|
39
39
|
studio: {
|
|
40
|
+
authenticated: workspace.authenticated,
|
|
40
41
|
auth: workspace.auth.token ? {token: workspace.auth.token} : undefined,
|
|
41
42
|
},
|
|
42
43
|
}
|
|
@@ -22,6 +22,9 @@ vi.mock('../../hooks/auth/useHandleAuthCallback', () => ({
|
|
|
22
22
|
vi.mock('../../hooks/auth/useLogOut', () => ({
|
|
23
23
|
useLogOut: vi.fn(() => async () => {}),
|
|
24
24
|
}))
|
|
25
|
+
vi.mock('../../hooks/comlink/useWindowConnection', () => ({
|
|
26
|
+
useWindowConnection: vi.fn(() => ({fetch: vi.fn()})),
|
|
27
|
+
}))
|
|
25
28
|
|
|
26
29
|
// Mock AuthError throwing scenario
|
|
27
30
|
vi.mock('./AuthError', async (importOriginal) => {
|
|
@@ -9,6 +9,10 @@ vi.mock('../../hooks/auth/useLogOut', () => ({
|
|
|
9
9
|
useLogOut: vi.fn(() => async () => {}),
|
|
10
10
|
}))
|
|
11
11
|
|
|
12
|
+
vi.mock('../../hooks/comlink/useWindowConnection', () => ({
|
|
13
|
+
useWindowConnection: vi.fn(() => ({fetch: vi.fn()})),
|
|
14
|
+
}))
|
|
15
|
+
|
|
12
16
|
describe('LoginError', () => {
|
|
13
17
|
it('shows authentication error and retry button', async () => {
|
|
14
18
|
const mockReset = vi.fn()
|
|
@@ -21,6 +25,7 @@ describe('LoginError', () => {
|
|
|
21
25
|
)
|
|
22
26
|
|
|
23
27
|
expect(screen.getByText('Authentication Error')).toBeInTheDocument()
|
|
28
|
+
|
|
24
29
|
const retryButton = screen.getByRole('button', {name: 'Retry'})
|
|
25
30
|
fireEvent.click(retryButton)
|
|
26
31
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {ClientError} from '@sanity/client'
|
|
2
|
+
import {SDK_CHANNEL_NAME, SDK_NODE_NAME} from '@sanity/message-protocol'
|
|
2
3
|
import {
|
|
3
4
|
AuthStateType,
|
|
4
5
|
getClientErrorApiBody,
|
|
@@ -10,6 +11,8 @@ import {type FallbackProps} from 'react-error-boundary'
|
|
|
10
11
|
|
|
11
12
|
import {useAuthState} from '../../hooks/auth/useAuthState'
|
|
12
13
|
import {useLogOut} from '../../hooks/auth/useLogOut'
|
|
14
|
+
import {useWindowConnection} from '../../hooks/comlink/useWindowConnection'
|
|
15
|
+
import {useSanityInstance} from '../../hooks/context/useSanityInstance'
|
|
13
16
|
import {Error} from '../errors/Error'
|
|
14
17
|
import {AuthError} from './AuthError'
|
|
15
18
|
import {ConfigurationError} from './ConfigurationError'
|
|
@@ -36,12 +39,23 @@ export function LoginError({error, resetErrorBoundary}: LoginErrorProps): React.
|
|
|
36
39
|
|
|
37
40
|
const logout = useLogOut()
|
|
38
41
|
const authState = useAuthState()
|
|
42
|
+
const {
|
|
43
|
+
config: {projectId},
|
|
44
|
+
} = useSanityInstance()
|
|
39
45
|
|
|
40
46
|
const [authErrorMessage, setAuthErrorMessage] = useState(
|
|
41
47
|
'Please try again or contact support if the problem persists.',
|
|
42
48
|
)
|
|
43
49
|
const [showRetryCta, setShowRetryCta] = useState(true)
|
|
44
50
|
|
|
51
|
+
/**
|
|
52
|
+
* TODO: before merge update message-protocol package to include the new message type
|
|
53
|
+
*/
|
|
54
|
+
const {fetch} = useWindowConnection({
|
|
55
|
+
name: SDK_NODE_NAME,
|
|
56
|
+
connectTo: SDK_CHANNEL_NAME,
|
|
57
|
+
})
|
|
58
|
+
|
|
45
59
|
const handleRetry = useCallback(async () => {
|
|
46
60
|
await logout()
|
|
47
61
|
resetErrorBoundary()
|
|
@@ -55,6 +69,13 @@ export function LoginError({error, resetErrorBoundary}: LoginErrorProps): React.
|
|
|
55
69
|
const description = getClientErrorApiDescription(error)
|
|
56
70
|
if (description) setAuthErrorMessage(description)
|
|
57
71
|
setShowRetryCta(false)
|
|
72
|
+
/**
|
|
73
|
+
* Handoff to dashboard to enable the request access flow for the project.
|
|
74
|
+
*/
|
|
75
|
+
fetch('dashboard/v1/auth/access/request', {
|
|
76
|
+
resourceType: 'project',
|
|
77
|
+
resourceId: projectId,
|
|
78
|
+
})
|
|
58
79
|
} else {
|
|
59
80
|
setShowRetryCta(true)
|
|
60
81
|
handleRetry()
|
|
@@ -73,7 +94,7 @@ export function LoginError({error, resetErrorBoundary}: LoginErrorProps): React.
|
|
|
73
94
|
setAuthErrorMessage(error.message)
|
|
74
95
|
setShowRetryCta(true)
|
|
75
96
|
}
|
|
76
|
-
}, [authState, handleRetry, error])
|
|
97
|
+
}, [authState, handleRetry, error, fetch, projectId])
|
|
77
98
|
|
|
78
99
|
return (
|
|
79
100
|
<Error
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import {type SanityConfig, type SanityInstance} from '@sanity/sdk'
|
|
2
2
|
import {act, render, screen} from '@testing-library/react'
|
|
3
3
|
import {StrictMode, use, useEffect} from 'react'
|
|
4
|
-
import {describe, expect, it} from 'vitest'
|
|
4
|
+
import {describe, expect, it, vi} from 'vitest'
|
|
5
5
|
|
|
6
6
|
import {ResourceProvider} from './ResourceProvider'
|
|
7
7
|
import {SanityInstanceContext} from './SanityInstanceContext'
|
|
@@ -37,6 +37,7 @@ describe('ResourceProvider', () => {
|
|
|
37
37
|
})
|
|
38
38
|
|
|
39
39
|
it('shows fallback during loading', async () => {
|
|
40
|
+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
40
41
|
const {promise, resolve} = promiseWithResolvers()
|
|
41
42
|
function SuspendingChild(): React.ReactNode {
|
|
42
43
|
throw promise
|
|
@@ -52,6 +53,8 @@ describe('ResourceProvider', () => {
|
|
|
52
53
|
act(() => {
|
|
53
54
|
resolve()
|
|
54
55
|
})
|
|
56
|
+
await new Promise((r) => setTimeout(r, 0))
|
|
57
|
+
consoleSpy.mockRestore()
|
|
55
58
|
})
|
|
56
59
|
|
|
57
60
|
it('creates root instance when no parent context exists', async () => {
|
|
@@ -141,6 +144,7 @@ describe('ResourceProvider', () => {
|
|
|
141
144
|
})
|
|
142
145
|
|
|
143
146
|
it('uses default fallback when none provided', async () => {
|
|
147
|
+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
144
148
|
const {promise, resolve} = promiseWithResolvers()
|
|
145
149
|
function SuspendingChild(): React.ReactNode {
|
|
146
150
|
throw promise
|
|
@@ -157,5 +161,7 @@ describe('ResourceProvider', () => {
|
|
|
157
161
|
act(() => {
|
|
158
162
|
resolve()
|
|
159
163
|
})
|
|
164
|
+
await new Promise((r) => setTimeout(r, 0))
|
|
165
|
+
consoleSpy.mockRestore()
|
|
160
166
|
})
|
|
161
167
|
})
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {createSanityInstance, type SanityConfig, type SanityInstance} from '@sanity/sdk'
|
|
2
|
+
import {initTelemetry} from '@sanity/sdk/_internal'
|
|
2
3
|
import {Suspense, useContext, useEffect, useMemo, useRef} from 'react'
|
|
3
4
|
|
|
4
5
|
import {SanityInstanceContext} from './SanityInstanceContext'
|
|
@@ -78,6 +79,11 @@ export function ResourceProvider({
|
|
|
78
79
|
[config, parent],
|
|
79
80
|
)
|
|
80
81
|
|
|
82
|
+
const projectId = config.projectId ?? ''
|
|
83
|
+
useMemo(() => {
|
|
84
|
+
if (projectId && !parent) initTelemetry(instance, projectId)
|
|
85
|
+
}, [instance, projectId, parent])
|
|
86
|
+
|
|
81
87
|
// Ref to hold the scheduled disposal timer.
|
|
82
88
|
const disposal = useRef<{
|
|
83
89
|
instance: SanityInstance
|
|
@@ -13,6 +13,12 @@ export interface StudioWorkspaceHandle {
|
|
|
13
13
|
projectId: string
|
|
14
14
|
/** The dataset name for this workspace. */
|
|
15
15
|
dataset: string
|
|
16
|
+
/**
|
|
17
|
+
* Whether the Studio has determined the user is authenticated.
|
|
18
|
+
* When `true` and the token source emits `null`, the SDK infers
|
|
19
|
+
* cookie-based auth is in use and skips the logged-out state.
|
|
20
|
+
*/
|
|
21
|
+
authenticated?: boolean
|
|
16
22
|
/** Authentication state for this workspace. */
|
|
17
23
|
auth: {
|
|
18
24
|
/**
|
|
@@ -37,11 +37,13 @@ describe('useDispatchIntent', () => {
|
|
|
37
37
|
})
|
|
38
38
|
|
|
39
39
|
it('should throw error when neither action nor intentId is provided', () => {
|
|
40
|
+
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
40
41
|
const {result} = renderHook(() => useDispatchIntent({documentHandle: mockDocumentHandle}))
|
|
41
42
|
|
|
42
43
|
expect(() => result.current.dispatchIntent()).toThrow(
|
|
43
44
|
'useDispatchIntent: Either `action` or `intentId` must be provided.',
|
|
44
45
|
)
|
|
46
|
+
consoleErrorSpy.mockRestore()
|
|
45
47
|
})
|
|
46
48
|
|
|
47
49
|
it('should handle errors gracefully', () => {
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import {renderHook, waitFor} from '@testing-library/react'
|
|
2
|
+
import {afterEach, describe, expect, it, vi} from 'vitest'
|
|
3
|
+
|
|
4
|
+
import {useWindowConnection} from '../comlink/useWindowConnection'
|
|
5
|
+
import {useWindowTitle} from './useWindowTitle'
|
|
6
|
+
|
|
7
|
+
vi.mock('../comlink/useWindowConnection', () => ({
|
|
8
|
+
useWindowConnection: vi.fn(),
|
|
9
|
+
}))
|
|
10
|
+
|
|
11
|
+
function createContextResponse(resource: Record<string, unknown>) {
|
|
12
|
+
return {context: {resource}}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('useWindowTitle', () => {
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
vi.clearAllMocks()
|
|
18
|
+
document.title = ''
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('should set the document title to the manifest title', async () => {
|
|
22
|
+
const mockFetch = vi.fn().mockResolvedValue(
|
|
23
|
+
createContextResponse({
|
|
24
|
+
type: 'application',
|
|
25
|
+
title: 'sdk-movie-list',
|
|
26
|
+
manifest: {title: 'Movie List App'},
|
|
27
|
+
}),
|
|
28
|
+
)
|
|
29
|
+
vi.mocked(useWindowConnection).mockReturnValue({
|
|
30
|
+
fetch: mockFetch,
|
|
31
|
+
sendMessage: vi.fn(),
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
renderHook(() => useWindowTitle())
|
|
35
|
+
|
|
36
|
+
await waitFor(() => {
|
|
37
|
+
expect(document.title).toBe('Movie List App')
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('should prefer manifest title over system title', async () => {
|
|
42
|
+
const mockFetch = vi.fn().mockResolvedValue(
|
|
43
|
+
createContextResponse({
|
|
44
|
+
type: 'application',
|
|
45
|
+
title: 'sdk-movie-list',
|
|
46
|
+
manifest: {title: 'Movie List App'},
|
|
47
|
+
activeDeployment: {manifest: {title: 'Deployed Title'}},
|
|
48
|
+
}),
|
|
49
|
+
)
|
|
50
|
+
vi.mocked(useWindowConnection).mockReturnValue({
|
|
51
|
+
fetch: mockFetch,
|
|
52
|
+
sendMessage: vi.fn(),
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
renderHook(() => useWindowTitle())
|
|
56
|
+
|
|
57
|
+
await waitFor(() => {
|
|
58
|
+
expect(document.title).toBe('Movie List App')
|
|
59
|
+
})
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('should fall back to activeDeployment manifest title', async () => {
|
|
63
|
+
const mockFetch = vi.fn().mockResolvedValue(
|
|
64
|
+
createContextResponse({
|
|
65
|
+
type: 'application',
|
|
66
|
+
title: 'sdk-movie-list',
|
|
67
|
+
manifest: null,
|
|
68
|
+
activeDeployment: {manifest: {title: 'Deployed Title'}},
|
|
69
|
+
}),
|
|
70
|
+
)
|
|
71
|
+
vi.mocked(useWindowConnection).mockReturnValue({
|
|
72
|
+
fetch: mockFetch,
|
|
73
|
+
sendMessage: vi.fn(),
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
renderHook(() => useWindowTitle())
|
|
77
|
+
|
|
78
|
+
await waitFor(() => {
|
|
79
|
+
expect(document.title).toBe('Deployed Title')
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('should fall back to system title when no manifest title exists', async () => {
|
|
84
|
+
const mockFetch = vi.fn().mockResolvedValue(
|
|
85
|
+
createContextResponse({
|
|
86
|
+
type: 'application',
|
|
87
|
+
title: 'sdk-movie-list',
|
|
88
|
+
manifest: null,
|
|
89
|
+
activeDeployment: null,
|
|
90
|
+
}),
|
|
91
|
+
)
|
|
92
|
+
vi.mocked(useWindowConnection).mockReturnValue({
|
|
93
|
+
fetch: mockFetch,
|
|
94
|
+
sendMessage: vi.fn(),
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
renderHook(() => useWindowTitle())
|
|
98
|
+
|
|
99
|
+
await waitFor(() => {
|
|
100
|
+
expect(document.title).toBe('sdk-movie-list')
|
|
101
|
+
})
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('should prepend view title when provided', async () => {
|
|
105
|
+
const mockFetch = vi.fn().mockResolvedValue(
|
|
106
|
+
createContextResponse({
|
|
107
|
+
type: 'application',
|
|
108
|
+
title: 'sdk-movie-list',
|
|
109
|
+
manifest: {title: 'Movie List App'},
|
|
110
|
+
}),
|
|
111
|
+
)
|
|
112
|
+
vi.mocked(useWindowConnection).mockReturnValue({
|
|
113
|
+
fetch: mockFetch,
|
|
114
|
+
sendMessage: vi.fn(),
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
renderHook(() => useWindowTitle('Movies'))
|
|
118
|
+
|
|
119
|
+
await waitFor(() => {
|
|
120
|
+
expect(document.title).toBe('Movies | Movie List App')
|
|
121
|
+
})
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('should update when view title changes', async () => {
|
|
125
|
+
const mockFetch = vi.fn().mockResolvedValue(
|
|
126
|
+
createContextResponse({
|
|
127
|
+
type: 'application',
|
|
128
|
+
title: 'sdk-movie-list',
|
|
129
|
+
manifest: {title: 'Movie List App'},
|
|
130
|
+
}),
|
|
131
|
+
)
|
|
132
|
+
vi.mocked(useWindowConnection).mockReturnValue({
|
|
133
|
+
fetch: mockFetch,
|
|
134
|
+
sendMessage: vi.fn(),
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
const {rerender} = renderHook(({viewTitle}) => useWindowTitle(viewTitle), {
|
|
138
|
+
initialProps: {viewTitle: 'Movies'},
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
await waitFor(() => {
|
|
142
|
+
expect(document.title).toBe('Movies | Movie List App')
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
rerender({viewTitle: 'Movie Details'})
|
|
146
|
+
|
|
147
|
+
await waitFor(() => {
|
|
148
|
+
expect(document.title).toBe('Movie Details | Movie List App')
|
|
149
|
+
})
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('should restore the previous title on unmount', async () => {
|
|
153
|
+
document.title = 'Previous Title'
|
|
154
|
+
|
|
155
|
+
const mockFetch = vi.fn().mockResolvedValue(
|
|
156
|
+
createContextResponse({
|
|
157
|
+
type: 'application',
|
|
158
|
+
title: 'sdk-movie-list',
|
|
159
|
+
manifest: {title: 'Movie List App'},
|
|
160
|
+
}),
|
|
161
|
+
)
|
|
162
|
+
vi.mocked(useWindowConnection).mockReturnValue({
|
|
163
|
+
fetch: mockFetch,
|
|
164
|
+
sendMessage: vi.fn(),
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
const {unmount} = renderHook(() => useWindowTitle('Movies'))
|
|
168
|
+
|
|
169
|
+
await waitFor(() => {
|
|
170
|
+
expect(document.title).toBe('Movies | Movie List App')
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
unmount()
|
|
174
|
+
|
|
175
|
+
expect(document.title).toBe('Previous Title')
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('should handle AbortError silently', async () => {
|
|
179
|
+
const abortError = new Error('Aborted')
|
|
180
|
+
abortError.name = 'AbortError'
|
|
181
|
+
const mockFetch = vi.fn().mockRejectedValue(abortError)
|
|
182
|
+
vi.mocked(useWindowConnection).mockReturnValue({
|
|
183
|
+
fetch: mockFetch,
|
|
184
|
+
sendMessage: vi.fn(),
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
renderHook(() => useWindowTitle())
|
|
188
|
+
|
|
189
|
+
await waitFor(() => {
|
|
190
|
+
expect(document.title).toBe('')
|
|
191
|
+
})
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it('should log non-abort fetch errors', async () => {
|
|
195
|
+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
196
|
+
const mockFetch = vi.fn().mockRejectedValue(new Error('Connection failed'))
|
|
197
|
+
vi.mocked(useWindowConnection).mockReturnValue({
|
|
198
|
+
fetch: mockFetch,
|
|
199
|
+
sendMessage: vi.fn(),
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
renderHook(() => useWindowTitle('Movies'))
|
|
203
|
+
|
|
204
|
+
await waitFor(() => {
|
|
205
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
206
|
+
'Failed to fetch app title from dashboard context:',
|
|
207
|
+
expect.any(Error),
|
|
208
|
+
)
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
consoleSpy.mockRestore()
|
|
212
|
+
})
|
|
213
|
+
})
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import {SDK_CHANNEL_NAME, SDK_NODE_NAME} from '@sanity/message-protocol'
|
|
2
|
+
import {useEffect, useState} from 'react'
|
|
3
|
+
|
|
4
|
+
import {useWindowConnection} from '../comlink/useWindowConnection'
|
|
5
|
+
|
|
6
|
+
interface ContextResource {
|
|
7
|
+
type: string
|
|
8
|
+
title?: string
|
|
9
|
+
manifest?: {
|
|
10
|
+
title?: string
|
|
11
|
+
} | null
|
|
12
|
+
activeDeployment?: {
|
|
13
|
+
manifest?: {
|
|
14
|
+
title?: string
|
|
15
|
+
} | null
|
|
16
|
+
} | null
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface ContextResponse {
|
|
20
|
+
context: {
|
|
21
|
+
resource: ContextResource
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function resolveAppTitle(resource: ContextResource): string | undefined {
|
|
26
|
+
return resource.manifest?.title ?? resource.activeDeployment?.manifest?.title ?? resource.title
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Sets the browser's document title, automatically including the app's name
|
|
31
|
+
* from the manifest.
|
|
32
|
+
*
|
|
33
|
+
* This follows the same convention as Sanity Studio workspaces, where the
|
|
34
|
+
* workspace name is always present in the title:
|
|
35
|
+
*
|
|
36
|
+
* - With a view title: `<viewTitle> | <appTitle>`
|
|
37
|
+
* - Without a view title: `<appTitle>`
|
|
38
|
+
*
|
|
39
|
+
* The Sanity dashboard appends `| Sanity` to produce the final browser tab title.
|
|
40
|
+
*
|
|
41
|
+
* @param viewTitle - An optional view-specific title to prepend to the app title.
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* ```tsx
|
|
45
|
+
* import {useWindowTitle} from '@sanity/sdk-react'
|
|
46
|
+
*
|
|
47
|
+
* function MoviesList() {
|
|
48
|
+
* useWindowTitle('Movies')
|
|
49
|
+
* return <div>...</div>
|
|
50
|
+
* }
|
|
51
|
+
*
|
|
52
|
+
* // Browser tab: "Movies | My App | Sanity"
|
|
53
|
+
* ```
|
|
54
|
+
*
|
|
55
|
+
* @example
|
|
56
|
+
* ```tsx
|
|
57
|
+
* // Call without arguments to show just the app title
|
|
58
|
+
* function AppRoot() {
|
|
59
|
+
* useWindowTitle()
|
|
60
|
+
* return <Outlet />
|
|
61
|
+
* }
|
|
62
|
+
*
|
|
63
|
+
* // Browser tab: "My App | Sanity"
|
|
64
|
+
* ```
|
|
65
|
+
*
|
|
66
|
+
* @public
|
|
67
|
+
*/
|
|
68
|
+
export function useWindowTitle(viewTitle?: string): void {
|
|
69
|
+
const [appTitle, setAppTitle] = useState<string | null>(null)
|
|
70
|
+
|
|
71
|
+
const {fetch} = useWindowConnection({
|
|
72
|
+
name: SDK_NODE_NAME,
|
|
73
|
+
connectTo: SDK_CHANNEL_NAME,
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
if (!fetch) return
|
|
78
|
+
|
|
79
|
+
const controller = new AbortController()
|
|
80
|
+
|
|
81
|
+
async function fetchAppTitle(signal: AbortSignal) {
|
|
82
|
+
try {
|
|
83
|
+
const data = await fetch<ContextResponse>('dashboard/v1/context', undefined, {signal})
|
|
84
|
+
const title = resolveAppTitle(data.context.resource)
|
|
85
|
+
if (title) {
|
|
86
|
+
setAppTitle(title)
|
|
87
|
+
}
|
|
88
|
+
} catch (err: unknown) {
|
|
89
|
+
if (err instanceof Error && err.name === 'AbortError') return
|
|
90
|
+
// eslint-disable-next-line no-console
|
|
91
|
+
console.error('Failed to fetch app title from dashboard context:', err)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
fetchAppTitle(controller.signal)
|
|
96
|
+
|
|
97
|
+
return () => {
|
|
98
|
+
controller.abort()
|
|
99
|
+
}
|
|
100
|
+
}, [fetch])
|
|
101
|
+
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
if (!appTitle) return
|
|
104
|
+
|
|
105
|
+
const previous = document.title
|
|
106
|
+
document.title = viewTitle ? `${viewTitle} | ${appTitle}` : appTitle
|
|
107
|
+
|
|
108
|
+
return () => {
|
|
109
|
+
document.title = previous
|
|
110
|
+
}
|
|
111
|
+
}, [viewTitle, appTitle])
|
|
112
|
+
}
|