@sanity/sdk-react 3.0.0-rc.1 → 3.0.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 (85) hide show
  1. package/dist/_exports/dashboard.d.ts +209 -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 +2186 -2774
  6. package/dist/index.d.ts.map +1 -0
  7. package/dist/index.js +3953 -1951
  8. package/dist/index.js.map +1 -1
  9. package/dist/useStudioWorkspacesByProjectIdDataset-I4S3CuR5.js +177 -0
  10. package/dist/useStudioWorkspacesByProjectIdDataset-I4S3CuR5.js.map +1 -0
  11. package/package.json +19 -19
  12. package/src/_exports/dashboard.ts +11 -0
  13. package/src/_exports/sdk-react.ts +11 -18
  14. package/src/components/SDKProvider.test.tsx +5 -5
  15. package/src/components/SanityApp.tsx +2 -3
  16. package/src/components/auth/AuthBoundary.test.tsx +1 -19
  17. package/src/components/auth/AuthBoundary.tsx +7 -6
  18. package/src/components/auth/LoginError.tsx +2 -3
  19. package/src/context/ComlinkTokenRefresh.tsx +3 -5
  20. package/src/context/{WorkbenchTokenRefresh.test.tsx → DashboardTokenRefresh.test.tsx} +26 -26
  21. package/src/context/{WorkbenchTokenRefresh.tsx → DashboardTokenRefresh.tsx} +14 -14
  22. package/src/context/OrganizationResourcesProvider.test.tsx +9 -9
  23. package/src/context/OrganizationResourcesProvider.tsx +2 -2
  24. package/src/context/ResourceProvider.test.tsx +21 -0
  25. package/src/context/ResourceProvider.tsx +4 -1
  26. package/src/context/{workbenchToken.ts → dashboardToken.ts} +13 -13
  27. package/src/hooks/agent/agentActions.test.tsx +1 -1
  28. package/src/hooks/agent/agentActions.ts +1 -1
  29. package/src/hooks/comlink/useFrameConnection.test.tsx +3 -2
  30. package/src/hooks/comlink/useFrameConnection.ts +1 -1
  31. package/src/hooks/comlink/useWindowConnection.test.tsx +4 -3
  32. package/src/hooks/comlink/useWindowConnection.ts +3 -4
  33. package/src/hooks/comments/useCommentActions.test.tsx +150 -0
  34. package/src/hooks/comments/useCommentActions.ts +109 -0
  35. package/src/hooks/comments/useCommentList.ts +79 -0
  36. package/src/hooks/comments/useCommentThreads.test.tsx +107 -0
  37. package/src/hooks/comments/useCommentThreads.ts +73 -0
  38. package/src/hooks/comments/useComments.test.tsx +242 -0
  39. package/src/hooks/comments/useComments.ts +59 -0
  40. package/src/hooks/context/useSanityInstance.test.tsx +2 -54
  41. package/src/hooks/context/useSanityInstance.ts +2 -24
  42. package/src/hooks/{agent → dashboard}/useAgentResourceContext.ts +2 -2
  43. package/src/hooks/dashboard/useFavorite.test.tsx +101 -0
  44. package/src/hooks/dashboard/useFavorite.ts +34 -0
  45. package/src/hooks/dashboard/useFavoriteContext.ts +61 -0
  46. package/src/hooks/dashboard/{useDashboardNavigate.test.ts → useNavigate.test.ts} +3 -3
  47. package/src/hooks/dashboard/{useDashboardNavigate.ts → useNavigate.ts} +5 -5
  48. package/src/hooks/dashboard/useNavigateToStudioDocument.ts +2 -1
  49. package/src/hooks/{auth/useDashboardOrganizationId.test.tsx → dashboard/useOrganizationId.test.tsx} +4 -4
  50. package/src/hooks/{auth/useDashboardOrganizationId.tsx → dashboard/useOrganizationId.tsx} +2 -2
  51. package/src/hooks/dashboard/useRecordDocumentHistoryEvent.ts +2 -1
  52. package/src/hooks/dashboard/useUpdateFavorite.test.tsx +146 -0
  53. package/src/hooks/dashboard/useUpdateFavorite.ts +74 -0
  54. package/src/hooks/dashboard/useWindowTitle.ts +1 -1
  55. package/src/hooks/datasets/useDatasets.test.tsx +29 -22
  56. package/src/hooks/datasets/useDatasets.ts +31 -53
  57. package/src/hooks/documents/useDocuments.ts +2 -7
  58. package/src/hooks/helpers/useNormalizedResourceOptions.test.tsx +0 -27
  59. package/src/hooks/helpers/useNormalizedResourceOptions.ts +14 -25
  60. package/src/hooks/paginatedDocuments/usePaginatedDocuments.ts +2 -7
  61. package/src/hooks/presence/usePresence.ts +14 -1
  62. package/src/hooks/presence/usePresenceForDocument.test.tsx +141 -0
  63. package/src/hooks/presence/usePresenceForDocument.ts +104 -0
  64. package/src/hooks/presence/useReportPresence.test.tsx +202 -0
  65. package/src/hooks/presence/useReportPresence.ts +170 -0
  66. package/src/hooks/preview/useDocumentPreview.tsx +2 -6
  67. package/src/hooks/projects/useProjects.ts +0 -8
  68. package/src/hooks/query/useQuery.ts +3 -8
  69. package/src/hooks/releases/useActiveReleases.ts +2 -2
  70. package/src/hooks/releases/useAllReleases.ts +2 -2
  71. package/src/hooks/users/useUser.ts +2 -8
  72. package/src/hooks/users/useUsers.ts +1 -2
  73. package/src/hooks/applications/useCreateUserApplication.test-d.ts +0 -14
  74. package/src/hooks/applications/useCreateUserApplication.ts +0 -11
  75. package/src/hooks/applications/useDeleteUserApplication.test-d.ts +0 -14
  76. package/src/hooks/applications/useDeleteUserApplication.ts +0 -11
  77. package/src/hooks/applications/useUpdateUserApplication.test-d.ts +0 -14
  78. package/src/hooks/applications/useUpdateUserApplication.ts +0 -11
  79. package/src/hooks/applications/useUserApplication.test-d.ts +0 -11
  80. package/src/hooks/applications/useUserApplication.ts +0 -14
  81. package/src/hooks/applications/useUserApplications.test-d.ts +0 -11
  82. package/src/hooks/applications/useUserApplications.ts +0 -14
  83. package/src/hooks/dashboard/useManageFavorite.test.tsx +0 -379
  84. package/src/hooks/dashboard/useManageFavorite.ts +0 -173
  85. /package/src/hooks/{agent → dashboard}/useAgentResourceContext.test.tsx +0 -0
@@ -42,7 +42,7 @@ function resolveAppTitle(resource: ContextResource): string | undefined {
42
42
  *
43
43
  * @example
44
44
  * ```tsx
45
- * import {useWindowTitle} from '@sanity/sdk-react'
45
+ * import {useWindowTitle} from '@sanity/sdk-react/dashboard'
46
46
  *
47
47
  * function MoviesList() {
48
48
  * useWindowTitle('Movies')
@@ -1,10 +1,6 @@
1
1
  import {type DatasetsResponse} from '@sanity/client'
2
- import {
3
- createSanityInstance,
4
- getDatasetsState,
5
- resolveDatasets,
6
- type StateSource,
7
- } from '@sanity/sdk'
2
+ import {createSanityInstance, datasets, type StateSource} from '@sanity/sdk'
3
+ import {type FetcherSnapshot} from '@sanity/sdk/_internal'
8
4
  import {type ReactNode} from 'react'
9
5
  import {type Observable} from 'rxjs'
10
6
  import {beforeEach, describe, expect, it, vi} from 'vitest'
@@ -16,32 +12,43 @@ import {useDatasets} from './useDatasets'
16
12
 
17
13
  vi.mock('@sanity/sdk', async (importOriginal) => {
18
14
  const original = await importOriginal<typeof import('@sanity/sdk')>()
19
- return {...original, getDatasetsState: vi.fn(), resolveDatasets: vi.fn()}
15
+ return {...original, datasets: {getState: vi.fn(), resolveState: vi.fn()}}
20
16
  })
21
17
 
22
18
  const stateSource = (
23
19
  current: DatasetsResponse | undefined,
24
- ): StateSource<DatasetsResponse | undefined> =>
25
- ({
26
- getCurrent: vi.fn(() => current),
27
- subscribe: vi.fn(),
20
+ ): StateSource<FetcherSnapshot<DatasetsResponse>> => {
21
+ // Cache the snapshot: useSyncExternalStore requires a referentially stable current value.
22
+ const snapshot = current
23
+ ? {status: 'success', data: current, error: undefined, isFetching: false, dataUpdatedAt: 1}
24
+ : {
25
+ status: 'pending',
26
+ data: undefined,
27
+ error: undefined,
28
+ isFetching: true,
29
+ dataUpdatedAt: undefined,
30
+ }
31
+ return {
32
+ getCurrent: vi.fn(() => snapshot),
33
+ subscribe: vi.fn(() => () => {}),
28
34
  get observable(): Observable<unknown> {
29
35
  throw new Error('Not implemented')
30
36
  },
31
- }) as unknown as StateSource<DatasetsResponse | undefined>
37
+ } as unknown as StateSource<FetcherSnapshot<DatasetsResponse>>
38
+ }
32
39
 
33
40
  const sanityInstance = expect.objectContaining({config: expect.any(Object)})
34
41
 
35
42
  describe('useDatasets', () => {
36
43
  beforeEach(() => {
37
44
  vi.clearAllMocks()
38
- vi.mocked(getDatasetsState).mockReturnValue(stateSource([] as unknown as DatasetsResponse))
45
+ vi.mocked(datasets.getState).mockReturnValue(stateSource([] as unknown as DatasetsResponse))
39
46
  })
40
47
 
41
48
  it('resolves the projectId from the instance config resource', () => {
42
49
  // test-utils wraps with ResourceProvider projectId="test" dataset="test".
43
50
  renderHook(() => useDatasets())
44
- expect(getDatasetsState).toHaveBeenCalledWith(
51
+ expect(datasets.getState).toHaveBeenCalledWith(
45
52
  sanityInstance,
46
53
  expect.objectContaining({projectId: 'test'}),
47
54
  )
@@ -49,7 +56,7 @@ describe('useDatasets', () => {
49
56
 
50
57
  it('lets an explicit projectId override the ambient resource', () => {
51
58
  renderHook(() => useDatasets({projectId: 'explicit-project'}))
52
- expect(getDatasetsState).toHaveBeenCalledWith(
59
+ expect(datasets.getState).toHaveBeenCalledWith(
53
60
  sanityInstance,
54
61
  expect.objectContaining({projectId: 'explicit-project'}),
55
62
  )
@@ -66,7 +73,7 @@ describe('useDatasets', () => {
66
73
  </ResourceProvider>
67
74
  ),
68
75
  })
69
- expect(getDatasetsState).toHaveBeenCalledWith(
76
+ expect(datasets.getState).toHaveBeenCalledWith(
70
77
  sanityInstance,
71
78
  expect.objectContaining({projectId: 'resource-project'}),
72
79
  )
@@ -82,7 +89,7 @@ describe('useDatasets', () => {
82
89
  })
83
90
  // A dataset-less config can't form a DatasetResource; the projectId is carried
84
91
  // via ProjectContext and injected so project-scoped reads still resolve it.
85
- expect(getDatasetsState).toHaveBeenCalledWith(
92
+ expect(datasets.getState).toHaveBeenCalledWith(
86
93
  sanityInstance,
87
94
  expect.objectContaining({projectId: 'config-project'}),
88
95
  )
@@ -101,16 +108,16 @@ describe('useDatasets', () => {
101
108
  </SanityInstanceContext.Provider>
102
109
  ),
103
110
  })
104
- expect(getDatasetsState).toHaveBeenCalledWith(
111
+ expect(datasets.getState).toHaveBeenCalledWith(
105
112
  emptyInstance,
106
113
  expect.objectContaining({projectId: 'bare-project'}),
107
114
  )
108
115
  })
109
116
 
110
- it('suspends via resolveDatasets until dataset data is available', () => {
111
- vi.mocked(getDatasetsState).mockReturnValue(stateSource(undefined))
112
- vi.mocked(resolveDatasets).mockReturnValue(new Promise(() => {}))
117
+ it('suspends via the datasets fetcher until dataset data is available', () => {
118
+ vi.mocked(datasets.getState).mockReturnValue(stateSource(undefined))
119
+ vi.mocked(datasets.resolveState).mockReturnValue(new Promise(() => {}))
113
120
  renderHook(() => useDatasets())
114
- expect(resolveDatasets).toHaveBeenCalled()
121
+ expect(datasets.resolveState).toHaveBeenCalled()
115
122
  })
116
123
  })
@@ -1,64 +1,42 @@
1
1
  import {type DatasetsResponse} from '@sanity/client'
2
- import {
3
- getDatasetsState,
4
- type ProjectHandle,
5
- resolveDatasets,
6
- type SanityInstance,
7
- type StateSource,
8
- } from '@sanity/sdk'
2
+ import {datasets, type ProjectHandle} from '@sanity/sdk'
9
3
 
10
- import {createStateSourceHook} from '../helpers/createStateSourceHook'
4
+ import {createFetcherHook, type FetcherHookResult} from '../helpers/createFetcherHook'
11
5
  import {useResolvedProjectId} from '../helpers/useResolvedProjectId'
12
6
 
13
- type UseDatasets = {
14
- /**
15
- *
16
- * Returns metadata for each dataset the current user has access to.
17
- *
18
- * @category Datasets
19
- * @param options - Optional project/resource to read datasets for. Defaults to
20
- * the resource named in `ResourceProvider`/`SDKProvider`.
21
- * @returns The metadata for your the datasets
22
- *
23
- * @example
24
- * ```tsx
25
- * const datasets = useDatasets()
26
- *
27
- * return (
28
- * <select>
29
- * {datasets.map((dataset) => (
30
- * <option key={dataset.name}>{dataset.name}</option>
31
- * ))}
32
- * </select>
33
- * )
34
- * ```
35
- *
36
- * @remarks
37
- * The `projectId` is resolved in order from:
38
- * 1. an explicit `projectId` option
39
- * 2. A legacy ProjectContext (e.g. a `<ResourceProvider projectId="…">` with no dataset), then
40
- * 3. The active resource (`ResourceProvider`/`SDKProvider`)
41
- * 4. `instance.config`.
42
- */
43
- (options?: ProjectHandle): DatasetsResponse
44
- }
45
-
46
- const useDatasetsBase = createStateSourceHook({
47
- getState: getDatasetsState as (
48
- instance: SanityInstance,
49
- projectHandle?: ProjectHandle,
50
- ) => StateSource<DatasetsResponse>,
51
- shouldSuspend: (instance, projectHandle?: ProjectHandle) =>
52
- // remove `undefined` since we're suspending when that is the case
53
- getDatasetsState(instance, projectHandle).getCurrent() === undefined,
54
- suspender: resolveDatasets,
55
- })
7
+ const useDatasetsBase = createFetcherHook(datasets)
56
8
 
57
9
  /**
10
+ * Returns metadata for each dataset the current user has access to.
11
+ *
12
+ * @category Datasets
13
+ * @param options - Optional project/resource to read datasets for. Defaults to
14
+ * the resource named in `ResourceProvider`/`SDKProvider`.
15
+ * @returns A {@link FetcherHookResult} whose `data` is the metadata for the
16
+ * datasets.
17
+ *
18
+ * @example
19
+ * ```tsx
20
+ * const {data: datasets} = useDatasets()
21
+ *
22
+ * return (
23
+ * <select>
24
+ * {datasets.map((dataset) => (
25
+ * <option key={dataset.name}>{dataset.name}</option>
26
+ * ))}
27
+ * </select>
28
+ * )
29
+ * ```
30
+ *
31
+ * @remarks
32
+ * The `projectId` is resolved in order from:
33
+ * 1. an explicit `projectId` option
34
+ * 2. A legacy ProjectContext (e.g. a `<ResourceProvider projectId="…">` with no dataset), then
35
+ * 3. The active resource (`ResourceProvider`/`SDKProvider`)
36
+ * 4. `instance.config`.
58
37
  * @public
59
- * @function
60
38
  */
61
- export const useDatasets: UseDatasets = (options) => {
39
+ export function useDatasets(options?: ProjectHandle): FetcherHookResult<DatasetsResponse> {
62
40
  const projectId = useResolvedProjectId(options)
63
41
  return useDatasetsBase(projectId ? {...options, projectId} : options)
64
42
  }
@@ -1,10 +1,5 @@
1
- import {
2
- createGroqSearchFilter,
3
- type DocumentHandle,
4
- isDatasetResource,
5
- type QueryOptions,
6
- } from '@sanity/sdk'
7
- import {pickProperties} from '@sanity/sdk/_internal'
1
+ import {type DocumentHandle, isDatasetResource, type QueryOptions} from '@sanity/sdk'
2
+ import {createGroqSearchFilter, pickProperties} from '@sanity/sdk/_internal'
8
3
  import {type SortOrderingItem} from '@sanity/types'
9
4
  import {useCallback, useMemo, useState} from 'react'
10
5
 
@@ -91,21 +91,6 @@ describe('useNormalizedResourceOptions', () => {
91
91
  ),
92
92
  ).toThrow()
93
93
  })
94
-
95
- it('resolves deprecated `source` as `resource`', () => {
96
- const {result} = renderHook(() =>
97
- useNormalizedResourceOptions({source: {projectId: 'src', dataset: 'src-ds'}}),
98
- )
99
- expect(result.current.resource).toEqual({projectId: 'src', dataset: 'src-ds'})
100
- })
101
-
102
- it('resolves deprecated `sourceName` as `resourceName`', () => {
103
- const {result} = renderHook(() => useNormalizedResourceOptions({sourceName: 'dataset'}))
104
- expect(result.current.resource).toEqual({
105
- projectId: 'resource-project-id',
106
- dataset: 'resource-dataset',
107
- })
108
- })
109
94
  })
110
95
 
111
96
  describe('tier 2 — bare projectId/dataset in options', () => {
@@ -232,18 +217,6 @@ describe('useNormalizedResourceOptions', () => {
232
217
  expect(result.current).not.toHaveProperty('dataset')
233
218
  })
234
219
 
235
- it('strips deprecated source from the result', () => {
236
- const {result} = renderHook(() =>
237
- useNormalizedResourceOptions({source: {projectId: 'src', dataset: 'src-ds'}}),
238
- )
239
- expect(result.current).not.toHaveProperty('source')
240
- })
241
-
242
- it('strips deprecated sourceName from the result', () => {
243
- const {result} = renderHook(() => useNormalizedResourceOptions({sourceName: 'dataset'}))
244
- expect(result.current).not.toHaveProperty('sourceName')
245
- })
246
-
247
220
  it('preserves unrelated fields', () => {
248
221
  const opts: DocumentHandle = {documentId: 'doc-1', documentType: 'article'}
249
222
  const {result} = renderHook(() => useNormalizedResourceOptions(opts))
@@ -6,7 +6,7 @@ import {PerspectiveContext} from '../../context/PerspectiveContext'
6
6
  import {ResourcesContext} from '../../context/ResourcesContext'
7
7
  import {SanityInstanceContext} from '../../context/SanityInstanceContext'
8
8
 
9
- type NormalizedResourceFields = 'resourceName' | 'source' | 'sourceName' | 'projectId' | 'dataset'
9
+ type NormalizedResourceFields = 'resourceName' | 'projectId' | 'dataset'
10
10
 
11
11
  /**
12
12
  * Adds React hook support (resourceName resolution) to core types.
@@ -24,11 +24,6 @@ export type WithResourceNameSupport<T extends {resource?: DocumentResource}> = T
24
24
  * @beta
25
25
  */
26
26
  resourceName?: string
27
- /**
28
- * @deprecated Use `resourceName` instead.
29
- * @beta
30
- */
31
- sourceName?: string
32
27
  }
33
28
 
34
29
  /**
@@ -48,8 +43,6 @@ export function normalizeResourceOptions<
48
43
  T extends {
49
44
  resource?: DocumentResource
50
45
  resourceName?: string
51
- source?: DocumentResource
52
- sourceName?: string
53
46
  projectId?: string
54
47
  dataset?: string
55
48
  perspective?: unknown
@@ -60,30 +53,27 @@ export function normalizeResourceOptions<
60
53
  contextResource?: DocumentResource,
61
54
  contextPerspective?: PerspectiveHandle['perspective'],
62
55
  ): Omit<T, NormalizedResourceFields> {
63
- const {resourceName, sourceName, source, projectId, dataset, ...rest} = options
64
-
65
- // Coalesce deprecated aliases to their canonical equivalents
66
- const effectiveResourceName = resourceName ?? sourceName
67
- const effectiveResource = options.resource ?? source
56
+ const {resourceName, projectId, dataset, ...rest} = options
57
+ const resource = options.resource
68
58
 
69
- if (effectiveResourceName && effectiveResource) {
59
+ if (resourceName && resource) {
70
60
  throw new Error(
71
- `Resource name ${JSON.stringify(effectiveResourceName)} and resource ${JSON.stringify(effectiveResource)} cannot be used together.`,
61
+ `Resource name ${JSON.stringify(resourceName)} and resource ${JSON.stringify(resource)} cannot be used together.`,
72
62
  )
73
63
  }
74
64
 
75
65
  let resolvedResource: DocumentResource | undefined
76
66
 
77
67
  // Tier (a): explicit resource object or resourceName lookup
78
- if (effectiveResource) {
79
- resolvedResource = effectiveResource
80
- } else if (effectiveResourceName) {
81
- if (!Object.hasOwn(resources, effectiveResourceName)) {
68
+ if (resource) {
69
+ resolvedResource = resource
70
+ } else if (resourceName) {
71
+ if (!Object.hasOwn(resources, resourceName)) {
82
72
  throw new Error(
83
- `There's no resource named ${JSON.stringify(effectiveResourceName)} in context. Please use <ResourceProvider>.`,
73
+ `There's no resource named ${JSON.stringify(resourceName)} in context. Please use <ResourceProvider>.`,
84
74
  )
85
75
  }
86
- resolvedResource = resources[effectiveResourceName]
76
+ resolvedResource = resources[resourceName]
87
77
  }
88
78
 
89
79
  // Tier (b): projectId or dataset in options → synthesize a resource
@@ -121,13 +111,14 @@ export function normalizeResourceOptions<
121
111
  export function useEffectiveContextResource(): DocumentResource | undefined {
122
112
  const contextResource = useContext(ResourceContext)
123
113
  const instance = useContext(SanityInstanceContext)
124
- const {projectId, dataset} = instance?.config ?? {}
114
+ const {projectId, dataset, resource: configResource} = instance?.config ?? {}
125
115
 
126
116
  return useMemo(() => {
127
117
  if (contextResource) return contextResource
118
+ if (configResource) return configResource
128
119
  if (projectId && dataset) return {projectId, dataset}
129
120
  return undefined
130
- }, [contextResource, projectId, dataset])
121
+ }, [contextResource, configResource, projectId, dataset])
131
122
  }
132
123
 
133
124
  /**
@@ -149,8 +140,6 @@ export function useNormalizedResourceOptions<
149
140
  T extends {
150
141
  resource?: DocumentResource
151
142
  resourceName?: string
152
- source?: DocumentResource
153
- sourceName?: string
154
143
  projectId?: string
155
144
  dataset?: string
156
145
  perspective?: PerspectiveHandle['perspective']
@@ -1,10 +1,5 @@
1
- import {
2
- createGroqSearchFilter,
3
- type DocumentHandle,
4
- isDatasetResource,
5
- type QueryOptions,
6
- } from '@sanity/sdk'
7
- import {pickProperties} from '@sanity/sdk/_internal'
1
+ import {type DocumentHandle, isDatasetResource, type QueryOptions} from '@sanity/sdk'
2
+ import {createGroqSearchFilter, pickProperties} from '@sanity/sdk/_internal'
8
3
  import {type SortOrderingItem} from '@sanity/types'
9
4
  import {useCallback, useMemo, useState} from 'react'
10
5
 
@@ -7,7 +7,20 @@ import {useNormalizedResourceOptions} from '../helpers/useNormalizedResourceOpti
7
7
  import {trackHookUsage} from '../helpers/useTrackHookUsage'
8
8
 
9
9
  /**
10
- * A hook for subscribing to presence information for the current project or Canvas.
10
+ * Every participant in the current project and dataset, or Canvas.
11
+ *
12
+ * Reading presence never announces anything. Call `useReportPresence` to make the
13
+ * current user visible to others, including to the Studio, which shares the same
14
+ * presence room.
15
+ *
16
+ * This returns everyone in the whole resource and leaves the filtering to you.
17
+ * Prefer `usePresenceForDocument` when you care about one document: it scopes and
18
+ * flattens the result for rendering. Note that participants are counted by session,
19
+ * so one person in two tabs appears twice.
20
+ *
21
+ * Presence is scoped to a single project and dataset. It is not a list of everyone
22
+ * signed in to your organization.
23
+ *
11
24
  * @public
12
25
  */
13
26
  export function usePresence(options: ResourceHandle = {}): {
@@ -0,0 +1,141 @@
1
+ import {type DocumentPresence, getDocumentPresence} from '@sanity/sdk'
2
+ import {act, renderHook} from '@testing-library/react'
3
+ import {beforeEach, describe, expect, it, vi} from 'vitest'
4
+
5
+ import {ResourceProvider} from '../../context/ResourceProvider'
6
+ import {usePresenceForDocument} from './usePresenceForDocument'
7
+
8
+ vi.mock('@sanity/sdk', async (importOriginal) => {
9
+ const actual = await importOriginal<typeof import('@sanity/sdk')>()
10
+ return {...actual, getDocumentPresence: vi.fn()}
11
+ })
12
+
13
+ const entry = (sessionId: string): DocumentPresence =>
14
+ ({
15
+ sessionId,
16
+ documentId: 'movie-1',
17
+ path: ['title'],
18
+ lastActiveAt: '2026-07-30T12:00:00Z',
19
+ user: {sanityUserId: 'u1', profile: {id: 'u1'}, memberships: []},
20
+ }) as unknown as DocumentPresence
21
+
22
+ /** A minimal StateSource stand-in whose value can be pushed. */
23
+ function createSource(initial: DocumentPresence[]) {
24
+ let current = initial
25
+ const listeners = new Set<() => void>()
26
+ return {
27
+ source: {
28
+ getCurrent: () => current,
29
+ subscribe: (cb: () => void) => {
30
+ listeners.add(cb)
31
+ return () => listeners.delete(cb)
32
+ },
33
+ },
34
+ push: (next: DocumentPresence[]) => {
35
+ current = next
36
+ listeners.forEach((cb) => cb())
37
+ },
38
+ }
39
+ }
40
+
41
+ const wrapper = ({children}: {children: React.ReactNode}) => (
42
+ <ResourceProvider projectId="p" dataset="d" fallback={null}>
43
+ {children}
44
+ </ResourceProvider>
45
+ )
46
+
47
+ describe('usePresenceForDocument', () => {
48
+ beforeEach(() => {
49
+ // Without this, `mock.calls[0]` is whatever the previous test did.
50
+ vi.clearAllMocks()
51
+ })
52
+
53
+ it('returns presence and updates when the store changes', () => {
54
+ const {source, push} = createSource([entry('s1')])
55
+ vi.mocked(getDocumentPresence).mockReturnValue(source as never)
56
+
57
+ const {result} = renderHook(
58
+ () => usePresenceForDocument({documentId: 'movie-1', documentType: 'movie'}),
59
+ {wrapper},
60
+ )
61
+
62
+ expect(result.current.presence).toHaveLength(1)
63
+
64
+ act(() => push([entry('s1'), entry('s2')]))
65
+ expect(result.current.presence).toHaveLength(2)
66
+ })
67
+
68
+ it('passes the document, path, and excludeVersions through', () => {
69
+ const {source} = createSource([])
70
+ vi.mocked(getDocumentPresence).mockReturnValue(source as never)
71
+
72
+ renderHook(
73
+ () =>
74
+ usePresenceForDocument({
75
+ documentId: 'drafts.movie-1',
76
+ documentType: 'movie',
77
+ path: ['cast', {_key: 'm1'}],
78
+ excludeVersions: true,
79
+ }),
80
+ {wrapper},
81
+ )
82
+
83
+ // Already a draft id, so resolution leaves it alone.
84
+ expect(vi.mocked(getDocumentPresence).mock.calls[0][1]).toMatchObject({
85
+ documentId: 'drafts.movie-1',
86
+ path: ['cast', {_key: 'm1'}],
87
+ excludeVersions: true,
88
+ })
89
+ })
90
+
91
+ it('reads the same id useReportPresence writes, or reads never match writes', () => {
92
+ const {source} = createSource([])
93
+ vi.mocked(getDocumentPresence).mockReturnValue(source as never)
94
+
95
+ // Forwarded unresolved, exactly as `useReportPresence` forwards it, so core
96
+ // resolves both the same way. If these diverged, field-level presence would
97
+ // silently never match.
98
+ renderHook(
99
+ () =>
100
+ usePresenceForDocument({
101
+ documentId: 'movie-1',
102
+ documentType: 'movie',
103
+ perspective: 'published',
104
+ }),
105
+ {wrapper},
106
+ )
107
+
108
+ expect(vi.mocked(getDocumentPresence).mock.calls[0][1]).toMatchObject({
109
+ documentId: 'movie-1',
110
+ perspective: 'published',
111
+ })
112
+ })
113
+
114
+ it('does not rebuild the source when a caller passes a fresh path each render', () => {
115
+ const {source} = createSource([])
116
+ vi.mocked(getDocumentPresence).mockReturnValue(source as never)
117
+
118
+ const {rerender} = renderHook(
119
+ // A new array identity every render, which is what real callers write.
120
+ () => usePresenceForDocument({documentId: 'movie-1', documentType: 'movie', path: ['title']}),
121
+ {wrapper},
122
+ )
123
+
124
+ rerender()
125
+ rerender()
126
+
127
+ expect(vi.mocked(getDocumentPresence)).toHaveBeenCalledTimes(1)
128
+ })
129
+
130
+ it('returns an empty array rather than undefined before anything arrives', () => {
131
+ const {source} = createSource(undefined as unknown as DocumentPresence[])
132
+ vi.mocked(getDocumentPresence).mockReturnValue(source as never)
133
+
134
+ const {result} = renderHook(
135
+ () => usePresenceForDocument({documentId: 'movie-1', documentType: 'movie'}),
136
+ {wrapper},
137
+ )
138
+
139
+ expect(result.current.presence).toEqual([])
140
+ })
141
+ })
@@ -0,0 +1,104 @@
1
+ import {type DocumentPresence, getDocumentPresence, isMediaLibraryResource} from '@sanity/sdk'
2
+ import {type Path} from '@sanity/types'
3
+ import {useCallback, useMemo, useSyncExternalStore} from 'react'
4
+
5
+ import {type DocumentHandle} from '../../config/handles'
6
+ import {useSanityInstance} from '../context/useSanityInstance'
7
+ import {useNormalizedResourceOptions} from '../helpers/useNormalizedResourceOptions'
8
+ import {trackHookUsage} from '../helpers/useTrackHookUsage'
9
+
10
+ /** @beta */
11
+ export interface UsePresenceForDocumentOptions extends DocumentHandle {
12
+ /**
13
+ * Narrows to participants at or below this field path, which is what a field
14
+ * indicator wants. Omit it for everyone in the document.
15
+ */
16
+ path?: Path
17
+
18
+ /**
19
+ * By default a draft, its published version, and any release versions count as
20
+ * the same document, which is what document lists want. Set this to compare ids
21
+ * exactly, so that a draft and a release version do not bleed into each other.
22
+ */
23
+ excludeVersions?: boolean
24
+ }
25
+
26
+ /**
27
+ * Who else is in a document, flattened to one entry per participant per location
28
+ * so it can be rendered straight against a field.
29
+ *
30
+ * Reading presence never announces anything. Use `useReportPresence` to make the
31
+ * current user visible to others.
32
+ *
33
+ * Prefer this over `usePresence` when you care about one document: `usePresence`
34
+ * returns every participant in the whole project and dataset, leaving the filtering
35
+ * to you.
36
+ *
37
+ * Resolves the document through its perspective exactly as `useReportPresence` does,
38
+ * so reads match writes. Participants are counted by session, so one person in two
39
+ * tabs appears twice.
40
+ *
41
+ * @example Avatars on a document
42
+ * ```tsx
43
+ * const {presence} = usePresenceForDocument({documentId, documentType})
44
+ * return presence.map((p) => <Avatar key={p.sessionId} user={p.user} />)
45
+ * ```
46
+ *
47
+ * @example Avatars on a single field
48
+ * ```tsx
49
+ * const {presence} = usePresenceForDocument({
50
+ * documentId,
51
+ * documentType,
52
+ * path: ['title'],
53
+ * excludeVersions: true,
54
+ * })
55
+ * ```
56
+ *
57
+ * @beta
58
+ */
59
+ export function usePresenceForDocument(options: UsePresenceForDocumentOptions): {
60
+ presence: DocumentPresence[]
61
+ } {
62
+ const {path, excludeVersions, ...handle} = options
63
+
64
+ const normalizedOptions = useNormalizedResourceOptions(handle)
65
+ if (normalizedOptions.resource && isMediaLibraryResource(normalizedOptions.resource)) {
66
+ throw new Error(
67
+ 'usePresenceForDocument() does not support media library resources. Presence tracking requires a canvas or dataset resource.',
68
+ )
69
+ }
70
+
71
+ const sanityInstance = useSanityInstance()
72
+ trackHookUsage(sanityInstance, 'usePresenceForDocument')
73
+
74
+ const {resource, perspective} = normalizedOptions
75
+ const {documentId, liveEdit} = options
76
+
77
+ // Serialized so a caller passing a fresh `path` array each render does not
78
+ // rebuild the state source every render.
79
+ const pathKey = useMemo(() => JSON.stringify(path ?? null), [path])
80
+
81
+ const source = useMemo(
82
+ () =>
83
+ getDocumentPresence(sanityInstance, {
84
+ ...(resource ? {resource} : {}),
85
+ documentId,
86
+ // Forwarded, not resolved here, so this matches what `useReportPresence`
87
+ // sends. Core owns turning a perspective into a specific document id.
88
+ ...(perspective ? {perspective} : {}),
89
+ ...(liveEdit ? {liveEdit} : {}),
90
+ ...(pathKey === 'null' ? {} : {path: JSON.parse(pathKey) as Path}),
91
+ ...(excludeVersions === undefined ? {} : {excludeVersions}),
92
+ }),
93
+ [sanityInstance, resource, documentId, perspective, liveEdit, pathKey, excludeVersions],
94
+ )
95
+
96
+ const subscribe = useCallback((callback: () => void) => source.subscribe(callback), [source])
97
+ const presence = useSyncExternalStore(
98
+ subscribe,
99
+ () => source.getCurrent(),
100
+ () => source.getCurrent(),
101
+ )
102
+
103
+ return {presence: presence || []}
104
+ }