@sanity/sdk-react 3.0.0-rc.2 → 3.1.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 (43) hide show
  1. package/dist/_exports/dashboard.d.ts +250 -0
  2. package/dist/_exports/dashboard.d.ts.map +1 -0
  3. package/dist/_exports/dashboard.js +278 -0
  4. package/dist/_exports/dashboard.js.map +1 -0
  5. package/dist/index.d.ts +86 -261
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +114 -616
  8. package/dist/index.js.map +1 -1
  9. package/dist/useStudioWorkspacesByProjectIdDataset-DxUlukmF.js +317 -0
  10. package/dist/useStudioWorkspacesByProjectIdDataset-DxUlukmF.js.map +1 -0
  11. package/package.json +12 -11
  12. package/src/_exports/dashboard.ts +12 -0
  13. package/src/_exports/sdk-react.ts +2 -12
  14. package/src/components/SDKProvider.test.tsx +5 -5
  15. package/src/components/auth/AuthBoundary.tsx +5 -5
  16. package/src/context/{WorkbenchTokenRefresh.test.tsx → DashboardTokenRefresh.test.tsx} +26 -26
  17. package/src/context/DashboardTokenRefresh.tsx +95 -0
  18. package/src/context/OrganizationResourcesProvider.test.tsx +9 -9
  19. package/src/context/OrganizationResourcesProvider.tsx +2 -2
  20. package/src/context/dashboardToken.ts +63 -0
  21. package/src/hooks/comlink/useWindowConnection.ts +1 -1
  22. package/src/hooks/{agent → dashboard}/useAgentResourceContext.ts +1 -1
  23. package/src/hooks/dashboard/useFavorite.test.tsx +101 -0
  24. package/src/hooks/dashboard/useFavorite.ts +34 -0
  25. package/src/hooks/dashboard/useFavoriteContext.ts +61 -0
  26. package/src/hooks/dashboard/{useDashboardNavigate.test.ts → useNavigate.test.ts} +3 -3
  27. package/src/hooks/dashboard/{useDashboardNavigate.ts → useNavigate.ts} +5 -5
  28. package/src/hooks/dashboard/useNavigateToStudioDocument.ts +2 -1
  29. package/src/hooks/{auth/useDashboardOrganizationId.test.tsx → dashboard/useOrganizationId.test.tsx} +4 -4
  30. package/src/hooks/{auth/useDashboardOrganizationId.tsx → dashboard/useOrganizationId.tsx} +2 -2
  31. package/src/hooks/dashboard/useUpdateFavorite.test.tsx +146 -0
  32. package/src/hooks/dashboard/useUpdateFavorite.ts +74 -0
  33. package/src/hooks/dashboard/useWindowTitle.ts +1 -1
  34. package/src/hooks/datasets/useDatasets.test.tsx +29 -22
  35. package/src/hooks/datasets/useDatasets.ts +31 -53
  36. package/src/hooks/document/useCreateDocument.ts +2 -1
  37. package/src/utils/resolveOrgResources.test.ts +2 -2
  38. package/src/utils/resolveOrgResources.ts +2 -2
  39. package/src/context/WorkbenchTokenRefresh.tsx +0 -61
  40. package/src/context/workbenchToken.ts +0 -63
  41. package/src/hooks/dashboard/useManageFavorite.test.tsx +0 -379
  42. package/src/hooks/dashboard/useManageFavorite.ts +0 -173
  43. /package/src/hooks/{agent → dashboard}/useAgentResourceContext.test.tsx +0 -0
@@ -4,13 +4,13 @@ import {of} from 'rxjs'
4
4
  import {afterEach, beforeEach, describe, expect, it, type Mock, vi} from 'vitest'
5
5
 
6
6
  import {useAuthState} from '../hooks/auth/useAuthState'
7
- import {ResourceProvider} from './ResourceProvider'
8
7
  import {
9
- isWorkbenchEnvironment,
10
- observeWorkbenchToken,
11
- refreshWorkbenchToken,
12
- } from './workbenchToken'
13
- import {WorkbenchTokenRefreshProvider} from './WorkbenchTokenRefresh'
8
+ isDashboardEnvironment,
9
+ observeDashboardToken,
10
+ refreshDashboardToken,
11
+ } from './dashboardToken'
12
+ import {DashboardTokenRefreshProvider} from './DashboardTokenRefresh'
13
+ import {ResourceProvider} from './ResourceProvider'
14
14
 
15
15
  vi.mock('@sanity/sdk', async () => {
16
16
  const actual = await vi.importActual('@sanity/sdk')
@@ -24,28 +24,28 @@ vi.mock('../hooks/auth/useAuthState', () => ({
24
24
  useAuthState: vi.fn(),
25
25
  }))
26
26
 
27
- vi.mock('./workbenchToken', () => ({
28
- isWorkbenchEnvironment: vi.fn(() => false),
29
- observeWorkbenchToken: vi.fn(() => undefined),
30
- refreshWorkbenchToken: vi.fn(),
27
+ vi.mock('./dashboardToken', () => ({
28
+ isDashboardEnvironment: vi.fn(() => false),
29
+ observeDashboardToken: vi.fn(() => undefined),
30
+ refreshDashboardToken: vi.fn(),
31
31
  }))
32
32
 
33
33
  const mockSetAuthToken = setAuthToken as Mock
34
34
  const mockUseAuthState = useAuthState as Mock
35
- const mockIsWorkbenchEnvironment = isWorkbenchEnvironment as Mock
36
- const mockObserveWorkbenchToken = observeWorkbenchToken as Mock
37
- const mockRefreshWorkbenchToken = refreshWorkbenchToken as Mock
35
+ const mockIsDashboardEnvironment = isDashboardEnvironment as Mock
36
+ const mockObserveDashboardToken = observeDashboardToken as Mock
37
+ const mockRefreshDashboardToken = refreshDashboardToken as Mock
38
38
 
39
39
  const renderProvider = () =>
40
40
  render(
41
41
  <ResourceProvider projectId="test-project" dataset="test-dataset" fallback={null}>
42
- <WorkbenchTokenRefreshProvider>
42
+ <DashboardTokenRefreshProvider>
43
43
  <div>Test</div>
44
- </WorkbenchTokenRefreshProvider>
44
+ </DashboardTokenRefreshProvider>
45
45
  </ResourceProvider>,
46
46
  )
47
47
 
48
- describe('WorkbenchTokenRefreshProvider', () => {
48
+ describe('DashboardTokenRefreshProvider', () => {
49
49
  beforeEach(() => {
50
50
  mockUseAuthState.mockReturnValue({type: AuthStateType.LOGGED_IN})
51
51
  })
@@ -54,9 +54,9 @@ describe('WorkbenchTokenRefreshProvider', () => {
54
54
  vi.clearAllMocks()
55
55
  })
56
56
 
57
- describe('when not in the workbench', () => {
57
+ describe('when not in the dashboard', () => {
58
58
  it('does not subscribe to the OS token', () => {
59
- mockIsWorkbenchEnvironment.mockReturnValue(false)
59
+ mockIsDashboardEnvironment.mockReturnValue(false)
60
60
 
61
61
  act(() => {
62
62
  renderProvider()
@@ -66,23 +66,23 @@ describe('WorkbenchTokenRefreshProvider', () => {
66
66
  })
67
67
  })
68
68
 
69
- describe('when in the workbench', () => {
69
+ describe('when in the dashboard', () => {
70
70
  beforeEach(() => {
71
- mockIsWorkbenchEnvironment.mockReturnValue(true)
71
+ mockIsDashboardEnvironment.mockReturnValue(true)
72
72
  })
73
73
 
74
74
  it('mirrors the OS token into the auth store', () => {
75
- mockObserveWorkbenchToken.mockReturnValue(of('workbench-token'))
75
+ mockObserveDashboardToken.mockReturnValue(of('dashboard-token'))
76
76
 
77
77
  act(() => {
78
78
  renderProvider()
79
79
  })
80
80
 
81
- expect(mockSetAuthToken).toHaveBeenCalledWith(expect.anything(), 'workbench-token')
81
+ expect(mockSetAuthToken).toHaveBeenCalledWith(expect.anything(), 'dashboard-token')
82
82
  })
83
83
 
84
84
  it('asks the OS to reissue the token on a 401', () => {
85
- mockObserveWorkbenchToken.mockReturnValue(of('workbench-token'))
85
+ mockObserveDashboardToken.mockReturnValue(of('dashboard-token'))
86
86
 
87
87
  const {rerender} = renderProvider()
88
88
 
@@ -93,14 +93,14 @@ describe('WorkbenchTokenRefreshProvider', () => {
93
93
  act(() => {
94
94
  rerender(
95
95
  <ResourceProvider projectId="test-project" dataset="test-dataset" fallback={null}>
96
- <WorkbenchTokenRefreshProvider>
96
+ <DashboardTokenRefreshProvider>
97
97
  <div>Test</div>
98
- </WorkbenchTokenRefreshProvider>
98
+ </DashboardTokenRefreshProvider>
99
99
  </ResourceProvider>,
100
100
  )
101
101
  })
102
102
 
103
- expect(mockRefreshWorkbenchToken).toHaveBeenCalledTimes(1)
103
+ expect(mockRefreshDashboardToken).toHaveBeenCalledTimes(1)
104
104
  })
105
105
  })
106
106
  })
@@ -0,0 +1,95 @@
1
+ import {type ClientError} from '@sanity/client'
2
+ import {AuthStateType, setAuthToken} from '@sanity/sdk'
3
+ import React, {type PropsWithChildren, useEffect, useRef} from 'react'
4
+
5
+ import {useAuthState} from '../hooks/auth/useAuthState'
6
+ import {useSanityInstance} from '../hooks/context/useSanityInstance'
7
+ import {
8
+ isDashboardEnvironment,
9
+ observeDashboardToken,
10
+ refreshDashboardToken,
11
+ } from './dashboardToken'
12
+
13
+ /**
14
+ * Keeps the SDK auth token in sync with the dashboard "OS".
15
+ *
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
+ * the auth store — a token logs us in, `null` logs us out, and later OS
19
+ * sign-in/out propagates automatically. When a request is rejected with a 401
20
+ * (the token expired), we ask the OS to reissue rather than tearing the session
21
+ * down; the new token arrives back through the same subscription.
22
+ */
23
+ function DashboardTokenRefresh({children}: PropsWithChildren) {
24
+ const instance = useSanityInstance()
25
+ const authState = useAuthState()
26
+ const processed401ErrorRef = useRef<unknown | null>(null)
27
+
28
+ useEffect(() => {
29
+ const token$ = observeDashboardToken()
30
+ if (!token$) return undefined
31
+ const subscription = token$.subscribe((token) => setAuthToken(instance, token))
32
+ return () => subscription.unsubscribe()
33
+ }, [instance])
34
+
35
+ useEffect(() => {
36
+ const has401Error =
37
+ authState.type === AuthStateType.ERROR && (authState.error as ClientError)?.statusCode === 401
38
+
39
+ if (has401Error && processed401ErrorRef.current !== authState.error) {
40
+ processed401ErrorRef.current = authState.error
41
+ refreshDashboardToken()
42
+ } else if (!has401Error) {
43
+ processed401ErrorRef.current = null
44
+ }
45
+ }, [authState])
46
+
47
+ return children
48
+ }
49
+
50
+ /**
51
+ * Authenticates the SDK with the Sanity Dashboard's session when the app runs
52
+ * inside the dashboard.
53
+ *
54
+ * The dashboard owns the session there: this provider subscribes to the token
55
+ * the dashboard issues, writes each new value into the SDK's auth store (where
56
+ * SDK hooks read it from), and asks the dashboard for a fresh token when a
57
+ * request fails with a 401. Outside the dashboard it renders children
58
+ * unchanged and the app's normal auth flow applies.
59
+ *
60
+ * @remarks
61
+ * `AuthBoundary` mounts this automatically, so most apps never need it
62
+ * directly. Mount it yourself only when your app runs inside the dashboard
63
+ * without `AuthBoundary` — that is, the app renders its own loading and error
64
+ * UI instead of the SDK's login flow — but still uses SDK hooks such as
65
+ * `useQuery`, which need the dashboard's token in the auth store to
66
+ * authenticate their requests.
67
+ *
68
+ * Mount it once, inside the provider that creates the Sanity instance whose
69
+ * store should receive the token.
70
+ *
71
+ * @example
72
+ * ```tsx
73
+ * import {ResourceProvider} from '@sanity/sdk-react'
74
+ * import {TokenRefreshProvider} from '@sanity/sdk-react/dashboard'
75
+ *
76
+ * function EmbeddedApp() {
77
+ * return (
78
+ * <ResourceProvider fallback={<Loading />}>
79
+ * <TokenRefreshProvider>
80
+ * <App />
81
+ * </TokenRefreshProvider>
82
+ * </ResourceProvider>
83
+ * )
84
+ * }
85
+ * ```
86
+ *
87
+ * @public
88
+ */
89
+ export const DashboardTokenRefreshProvider: React.FC<PropsWithChildren> = ({children}) => {
90
+ if (isDashboardEnvironment()) {
91
+ return <DashboardTokenRefresh>{children}</DashboardTokenRefresh>
92
+ }
93
+
94
+ return children
95
+ }
@@ -3,7 +3,7 @@ import {act, render} from '@testing-library/react'
3
3
  import {type ReactNode, Suspense, useContext, useEffect} from 'react'
4
4
  import {beforeEach, describe, expect, it, vi} from 'vitest'
5
5
 
6
- import {useDashboardOrganizationId} from '../hooks/auth/useDashboardOrganizationId'
6
+ import {useOrganizationId} from '../hooks/dashboard/useOrganizationId'
7
7
  import {resolveOrgResources} from '../utils/resolveOrgResources'
8
8
  import {OrganizationResourcesProvider} from './OrganizationResourcesProvider'
9
9
  import {ResourcesContext} from './ResourcesContext'
@@ -28,8 +28,8 @@ vi.mock('../hooks/context/useSanityInstance', () => {
28
28
  return {useSanityInstance: () => instance}
29
29
  })
30
30
 
31
- vi.mock('../hooks/auth/useDashboardOrganizationId', () => ({
32
- useDashboardOrganizationId: vi.fn(),
31
+ vi.mock('../hooks/dashboard/useOrganizationId', () => ({
32
+ useOrganizationId: vi.fn(),
33
33
  }))
34
34
 
35
35
  vi.mock('../utils/resolveOrgResources', () => ({
@@ -37,14 +37,14 @@ vi.mock('../utils/resolveOrgResources', () => ({
37
37
  }))
38
38
 
39
39
  const mockResolveOrgResources = vi.mocked(resolveOrgResources)
40
- const mockUseDashboardOrganizationId = vi.mocked(useDashboardOrganizationId)
40
+ const mockUseOrganizationId = vi.mocked(useOrganizationId)
41
41
 
42
42
  describe('OrganizationResourcesProvider', () => {
43
43
  beforeEach(() => {
44
44
  vi.clearAllMocks()
45
45
  instance = createSanityInstance()
46
46
  mockResolveOrgResources.mockResolvedValue({})
47
- mockUseDashboardOrganizationId.mockReturnValue(undefined)
47
+ mockUseOrganizationId.mockReturnValue(undefined)
48
48
  })
49
49
 
50
50
  // Captures ResourcesContext via useEffect (side-effect territory, not render)
@@ -91,7 +91,7 @@ describe('OrganizationResourcesProvider', () => {
91
91
  })
92
92
 
93
93
  it('suspends children while org resources are being fetched, then renders with resolved resources', async () => {
94
- mockUseDashboardOrganizationId.mockReturnValue('org-suspend-test')
94
+ mockUseOrganizationId.mockReturnValue('org-suspend-test')
95
95
  const {promise, resolve} = promiseWithResolvers<void>()
96
96
  mockResolveOrgResources.mockReturnValue(
97
97
  promise.then(() => ({
@@ -115,7 +115,7 @@ describe('OrganizationResourcesProvider', () => {
115
115
  })
116
116
 
117
117
  it('renders without inferred entries and logs a warning when resolveOrgResources rejects', async () => {
118
- mockUseDashboardOrganizationId.mockReturnValue('org-warn-test')
118
+ mockUseOrganizationId.mockReturnValue('org-warn-test')
119
119
  const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
120
120
  mockResolveOrgResources.mockRejectedValue(new Error('fetch failed'))
121
121
 
@@ -135,7 +135,7 @@ describe('OrganizationResourcesProvider', () => {
135
135
  })
136
136
 
137
137
  it('caches Promise references across providers for the same instance', () => {
138
- mockUseDashboardOrganizationId.mockReturnValue('org-cache-test')
138
+ mockUseOrganizationId.mockReturnValue('org-cache-test')
139
139
  render(
140
140
  <Suspense fallback={null}>
141
141
  <OrganizationResourcesProvider inferMediaLibraryAndCanvas>
@@ -168,7 +168,7 @@ describe('OrganizationResourcesProvider', () => {
168
168
  })
169
169
 
170
170
  it('explicit resources take precedence over inferred ones', async () => {
171
- mockUseDashboardOrganizationId.mockReturnValue('org-override-test')
171
+ mockUseOrganizationId.mockReturnValue('org-override-test')
172
172
  mockResolveOrgResources.mockResolvedValue({
173
173
  mediaLibrary: {mediaLibraryId: 'inferred-ml'},
174
174
  canvas: {canvasId: 'inferred-canvas'},
@@ -1,8 +1,8 @@
1
1
  import {type DocumentResource, type SanityInstance} from '@sanity/sdk'
2
2
  import {type ReactElement, type ReactNode, use, useMemo} from 'react'
3
3
 
4
- import {useDashboardOrganizationId} from '../hooks/auth/useDashboardOrganizationId'
5
4
  import {useSanityInstance} from '../hooks/context/useSanityInstance'
5
+ import {useOrganizationId} from '../hooks/dashboard/useOrganizationId'
6
6
  import {resolveOrgResources} from '../utils/resolveOrgResources'
7
7
  import {ResourcesContext} from './ResourcesContext'
8
8
 
@@ -91,7 +91,7 @@ export function OrganizationResourcesProvider({
91
91
  children: ReactNode
92
92
  }): ReactElement {
93
93
  const instance = useSanityInstance()
94
- const orgId = useDashboardOrganizationId()
94
+ const orgId = useOrganizationId()
95
95
 
96
96
  if (!inferMediaLibraryAndCanvas || !orgId) {
97
97
  return (
@@ -0,0 +1,63 @@
1
+ import {from, type Observable, of} from 'rxjs'
2
+ import {catchError, switchMap} from 'rxjs/operators'
3
+
4
+ // The dashboard host installs its shared message bus on this well-known global
5
+ // symbol before it loads the apps it embeds in its own window. It must match
6
+ // the key used by `@sanity/workbench` (`Symbol.for('sanity.os.bus')`).
7
+ const OS_BUS_KEY = Symbol.for('sanity.os.bus')
8
+
9
+ /**
10
+ * Whether this app is running inside the dashboard, embedded in its window.
11
+ *
12
+ * Apps embedded this way share the dashboard's realm, so the bus it installs
13
+ * is visible on `globalThis`. This is `false` in a standalone app, where we
14
+ * must never import `@sanity/workbench` (it would install a bus and add bundle
15
+ * weight for no reason). Note: this is a different embedding model to the Core
16
+ * UI iframe, which is detected separately via the dashboard context — that
17
+ * signal is not set for apps sharing the dashboard's window.
18
+ *
19
+ * @internal
20
+ */
21
+ export function isDashboardEnvironment(): boolean {
22
+ return typeof globalThis === 'object' && OS_BUS_KEY in globalThis
23
+ }
24
+
25
+ /**
26
+ * Observes the session token issued by the dashboard "OS", tracking the OS auth
27
+ * state over time.
28
+ *
29
+ * Returns `undefined` when the app is not embedded in the dashboard, so the
30
+ * caller uses its normal auth flow. Inside the dashboard, subscribes to the
31
+ * `auth.token` state topic, emitting the current token — or `null` when the OS
32
+ * is signed out — and re-emitting as the OS auth state changes, so sign-in/out
33
+ * propagates instead of being captured once. Any bus error is treated as "no
34
+ * token" (`null`). The token is used in-memory only and never persisted.
35
+ *
36
+ * @internal
37
+ */
38
+ export function observeDashboardToken(): Observable<string | null> | undefined {
39
+ if (!isDashboardEnvironment()) return undefined
40
+
41
+ return from(import('@sanity/workbench')).pipe(
42
+ switchMap(({os}) => os.subscribe('auth.token')),
43
+ // Any failure (importing the host bundle, or the subscription) means "no OS token".
44
+ catchError(() => of(null)),
45
+ )
46
+ }
47
+
48
+ /**
49
+ * Asks the dashboard "OS" to reissue the session token, e.g. after its current
50
+ * one was rejected with a 401. Fire-and-forget: the reissued token arrives via
51
+ * the `auth.token` subscription in {@link observeDashboardToken}. No-op outside
52
+ * the dashboard.
53
+ *
54
+ * @internal
55
+ */
56
+ export function refreshDashboardToken(): void {
57
+ if (!isDashboardEnvironment()) return
58
+
59
+ void import('@sanity/workbench').then(
60
+ ({os}) => os.emit('auth.token.refresh', undefined),
61
+ () => {},
62
+ )
63
+ }
@@ -66,7 +66,7 @@ const useNodeState = createStateSourceHook({
66
66
  * as well as sharing a single node between invocations if they share the same name.
67
67
  *
68
68
  * Generally not to be used directly, but to be used as a dependency of
69
- * Comlink-powered hooks like `useManageFavorite`.
69
+ * Comlink-powered hooks like `useStudioWorkspacesByProjectIdDataset`.
70
70
  */
71
71
  export function useWindowConnection<
72
72
  TWindowMessage extends WindowMessage,
@@ -36,7 +36,7 @@ export interface AgentResourceContextOptions {
36
36
  *
37
37
  * @example
38
38
  * ```tsx
39
- * import {useAgentResourceContext} from '@sanity/sdk-react'
39
+ * import {useAgentResourceContext} from '@sanity/sdk-react/dashboard'
40
40
  *
41
41
  * function MyComponent() {
42
42
  * const documentId = 'my-document-id'
@@ -0,0 +1,101 @@
1
+ import {favorites, type FavoriteStatusResponse, type StateSource} from '@sanity/sdk'
2
+ import {type FetcherSnapshot} from '@sanity/sdk/_internal'
3
+ import {renderHook} from '@testing-library/react'
4
+ import {type ReactNode} from 'react'
5
+ import {BehaviorSubject} from 'rxjs'
6
+ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
7
+
8
+ import {ResourceProvider} from '../../context/ResourceProvider'
9
+ import {useFavorite} from './useFavorite'
10
+
11
+ vi.mock(import('@sanity/sdk'), async (importOriginal) => {
12
+ const actual = await importOriginal()
13
+ return {
14
+ ...actual,
15
+ favorites: {
16
+ getState: vi.fn(),
17
+ resolveState: vi.fn(),
18
+ refetch: vi.fn(),
19
+ },
20
+ } as unknown as typeof actual
21
+ })
22
+
23
+ describe('useFavorite', () => {
24
+ let subject: BehaviorSubject<FavoriteStatusResponse>
25
+
26
+ const handle = {
27
+ documentId: 'mock-id',
28
+ documentType: 'mock-type',
29
+ resourceType: 'studio' as const,
30
+ }
31
+
32
+ const toSnapshot = (value: FavoriteStatusResponse): FetcherSnapshot<FavoriteStatusResponse> => ({
33
+ status: 'success',
34
+ data: value,
35
+ error: undefined,
36
+ isFetching: false,
37
+ dataUpdatedAt: 1,
38
+ })
39
+
40
+ const wrapper = ({children}: {children: ReactNode}) => (
41
+ <ResourceProvider projectId="test" dataset="test" fallback={null}>
42
+ {children}
43
+ </ResourceProvider>
44
+ )
45
+
46
+ beforeEach(() => {
47
+ subject = new BehaviorSubject<FavoriteStatusResponse>({isFavorited: false})
48
+ vi.mocked(favorites.getState).mockImplementation(
49
+ () =>
50
+ ({
51
+ subscribe: (callback?: () => void) => {
52
+ if (!callback) return () => {}
53
+ const subscription = subject.subscribe(() => callback())
54
+ callback()
55
+ return () => subscription.unsubscribe()
56
+ },
57
+ getCurrent: () => toSnapshot(subject.getValue()),
58
+ observable: subject.asObservable(),
59
+ }) as unknown as StateSource<FetcherSnapshot<FavoriteStatusResponse>>,
60
+ )
61
+ })
62
+
63
+ afterEach(() => {
64
+ subject.complete()
65
+ vi.clearAllMocks()
66
+ })
67
+
68
+ it('returns false when the document is not favorited', () => {
69
+ const {result} = renderHook(() => useFavorite(handle), {wrapper})
70
+ expect(result.current).toBe(false)
71
+ })
72
+
73
+ it('reflects the favorited status from the store', () => {
74
+ subject.next({isFavorited: true})
75
+ const {result} = renderHook(() => useFavorite(handle), {wrapper})
76
+ expect(result.current).toBe(true)
77
+ })
78
+
79
+ it('suspends until the favorite status is available', () => {
80
+ const pending: FetcherSnapshot<FavoriteStatusResponse> = {
81
+ status: 'pending',
82
+ data: undefined,
83
+ error: undefined,
84
+ isFetching: true,
85
+ dataUpdatedAt: undefined,
86
+ }
87
+ vi.mocked(favorites.getState).mockImplementation(
88
+ () =>
89
+ ({
90
+ subscribe: () => () => {},
91
+ getCurrent: () => pending,
92
+ observable: subject.asObservable(),
93
+ }) as unknown as StateSource<FetcherSnapshot<FavoriteStatusResponse>>,
94
+ )
95
+ vi.mocked(favorites.resolveState).mockReturnValue(new Promise(() => {}))
96
+ const {result} = renderHook(() => useFavorite(handle), {wrapper})
97
+ // Suspended on the initial fetch — the ResourceProvider fallback renders instead.
98
+ expect(result.current).toBeNull()
99
+ expect(favorites.resolveState).toHaveBeenCalled()
100
+ })
101
+ })
@@ -0,0 +1,34 @@
1
+ import {favorites} from '@sanity/sdk'
2
+
3
+ import {createFetcherHook} from '../helpers/createFetcherHook'
4
+ import {useFavoriteContext, type UseFavoriteProps} from './useFavoriteContext'
5
+
6
+ const useFavoriteStatus = createFetcherHook(favorites)
7
+
8
+ /**
9
+ * @internal
10
+ *
11
+ * Reads whether a document is currently favorited. The write-side counterpart is
12
+ * {@link useUpdateFavorite}.
13
+ *
14
+ * The hook suspends until the first favorite status resolves, so wrap the
15
+ * component in a `<Suspense>` boundary.
16
+ *
17
+ * @param props - The document handle plus the resource it lives in.
18
+ * @returns `true` when the document is favorited, otherwise `false`.
19
+ *
20
+ * @example
21
+ * ```tsx
22
+ * function FavoriteLabel(props: DocumentActionProps) {
23
+ * const {documentId, documentType} = props
24
+ * const isFavorited = useFavorite({documentId, documentType, resourceType: 'studio'})
25
+ *
26
+ * return <span>{isFavorited ? 'Favorited' : 'Not favorited'}</span>
27
+ * }
28
+ * ```
29
+ */
30
+ export function useFavorite(props: UseFavoriteProps): boolean {
31
+ const context = useFavoriteContext(props)
32
+ const {data} = useFavoriteStatus(context)
33
+ return data.isFavorited
34
+ }
@@ -0,0 +1,61 @@
1
+ import {
2
+ type CanvasResource,
3
+ type MediaResource,
4
+ type StudioResource,
5
+ } from '@sanity/message-protocol'
6
+ import {type DocumentHandle, type FavoriteDocumentContext} from '@sanity/sdk'
7
+ import {useMemo} from 'react'
8
+
9
+ import {useSanityInstance} from '../context/useSanityInstance'
10
+
11
+ /**
12
+ * Props shared by {@link useFavorite} and {@link useUpdateFavorite}: a document
13
+ * handle plus the resource it lives in.
14
+ *
15
+ * @internal
16
+ */
17
+ export interface UseFavoriteProps extends DocumentHandle {
18
+ resourceId?: string
19
+ resourceType: StudioResource['type'] | MediaResource['type'] | CanvasResource['type']
20
+ /**
21
+ * The name of the schema collection this document belongs to.
22
+ * Typically is the name of the workspace when used in the context of a studio.
23
+ */
24
+ schemaName?: string
25
+ }
26
+
27
+ /**
28
+ * Resolves {@link UseFavoriteProps} into the {@link FavoriteDocumentContext}
29
+ * shared by the favorites fetcher and mutation, defaulting a studio `resourceId`
30
+ * from the instance's project and dataset.
31
+ *
32
+ * @internal
33
+ */
34
+ export function useFavoriteContext({
35
+ documentId,
36
+ documentType,
37
+ projectId: paramProjectId,
38
+ dataset: paramDataset,
39
+ resourceId: paramResourceId,
40
+ resourceType,
41
+ schemaName,
42
+ }: UseFavoriteProps): FavoriteDocumentContext {
43
+ const {config} = useSanityInstance()
44
+ const projectId = paramProjectId ?? config?.projectId
45
+ const dataset = paramDataset ?? config?.dataset
46
+
47
+ if (resourceType === 'studio' && (!projectId || !dataset)) {
48
+ throw new Error('projectId and dataset are required for studio resources')
49
+ }
50
+ const resourceId =
51
+ resourceType === 'studio' && !paramResourceId ? `${projectId}.${dataset}` : paramResourceId
52
+
53
+ if (!resourceId) {
54
+ throw new Error('resourceId is required for media-library and canvas resources')
55
+ }
56
+
57
+ return useMemo(
58
+ () => ({documentId, documentType, resourceId, resourceType, schemaName}),
59
+ [documentId, documentType, resourceId, resourceType, schemaName],
60
+ )
61
+ }
@@ -2,7 +2,7 @@ import {type PathChangeMessage} from '@sanity/message-protocol'
2
2
  import {renderHook} from '@testing-library/react'
3
3
  import {beforeEach, describe, expect, it, vi} from 'vitest'
4
4
 
5
- import {useDashboardNavigate} from './useDashboardNavigate'
5
+ import {useNavigate} from './useNavigate'
6
6
 
7
7
  const mockOnMessage = vi.fn()
8
8
  let mockMessageHandler: ((data: PathChangeMessage['data']) => void) | undefined
@@ -22,7 +22,7 @@ vi.mock('../comlink/useWindowConnection', () => {
22
22
  }
23
23
  })
24
24
 
25
- describe('useDashboardNavigate', () => {
25
+ describe('useNavigate', () => {
26
26
  const mockNavigateFn = vi.fn()
27
27
 
28
28
  beforeEach(() => {
@@ -31,7 +31,7 @@ describe('useDashboardNavigate', () => {
31
31
  })
32
32
 
33
33
  it('calls navigate function with correct data when message is received', () => {
34
- renderHook(() => useDashboardNavigate(mockNavigateFn))
34
+ renderHook(() => useNavigate(mockNavigateFn))
35
35
 
36
36
  const mockNavigationData = {
37
37
  path: '/test-path',
@@ -21,13 +21,13 @@ import {useWindowConnection} from '../comlink/useWindowConnection'
21
21
  *
22
22
  * @example
23
23
  * ```tsx
24
- * import {useDashboardNavigate} from '@sanity/sdk-react'
25
- * import {BrowserRouter, useNavigate} from 'react-router'
24
+ * import {useNavigate} from '@sanity/sdk-react/dashboard'
25
+ * import {BrowserRouter, useNavigate as useRouterNavigate} from 'react-router'
26
26
  * import {Suspense} from 'react'
27
27
  *
28
28
  * function DashboardNavigationHandler() {
29
- * const navigate = useNavigate()
30
- * useDashboardNavigate(({path, type}) => {
29
+ * const navigate = useRouterNavigate()
30
+ * useNavigate(({path, type}) => {
31
31
  * navigate(path, {replace: type === 'replace'})
32
32
  * })
33
33
  * return null
@@ -45,7 +45,7 @@ import {useWindowConnection} from '../comlink/useWindowConnection'
45
45
  * }
46
46
  * ```
47
47
  */
48
- export function useDashboardNavigate(
48
+ export function useNavigate(
49
49
  navigateFn: (options: PathChangeMessage['data']) => void,
50
50
  ): void {
51
51
  useWindowConnection<PathChangeMessage, never>({
@@ -36,7 +36,8 @@ export interface NavigateToStudioResult {
36
36
  *
37
37
  * @example
38
38
  * ```tsx
39
- * import {useNavigateToStudioDocument, type DocumentHandle} from '@sanity/sdk-react'
39
+ * import {type DocumentHandle} from '@sanity/sdk-react'
40
+ * import {useNavigateToStudioDocument} from '@sanity/sdk-react/dashboard'
40
41
  * import {Button} from '@sanity/ui'
41
42
  * import {Suspense} from 'react'
42
43
  *