@sanity/sdk 2.8.0 → 2.10.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 (111) hide show
  1. package/dist/_chunks-dts/utils.d.ts +2450 -0
  2. package/dist/_chunks-es/_internal.js +129 -0
  3. package/dist/_chunks-es/_internal.js.map +1 -0
  4. package/dist/_chunks-es/createGroqSearchFilter.js +1537 -0
  5. package/dist/_chunks-es/createGroqSearchFilter.js.map +1 -0
  6. package/dist/_chunks-es/telemetryManager.js +87 -0
  7. package/dist/_chunks-es/telemetryManager.js.map +1 -0
  8. package/dist/_chunks-es/version.js +7 -0
  9. package/dist/_chunks-es/version.js.map +1 -0
  10. package/dist/_exports/_internal.d.ts +64 -0
  11. package/dist/_exports/_internal.js +20 -0
  12. package/dist/_exports/_internal.js.map +1 -0
  13. package/dist/index.d.ts +2 -2343
  14. package/dist/index.js +465 -1813
  15. package/dist/index.js.map +1 -1
  16. package/package.json +17 -12
  17. package/src/_exports/_internal.ts +14 -0
  18. package/src/_exports/index.ts +18 -1
  19. package/src/auth/authStore.test.ts +150 -1
  20. package/src/auth/authStore.ts +11 -11
  21. package/src/auth/dashboardAuth.ts +2 -2
  22. package/src/auth/handleAuthCallback.ts +9 -3
  23. package/src/auth/logout.test.ts +1 -1
  24. package/src/auth/logout.ts +1 -1
  25. package/src/auth/refreshStampedToken.test.ts +118 -1
  26. package/src/auth/refreshStampedToken.ts +3 -2
  27. package/src/auth/standaloneAuth.ts +9 -3
  28. package/src/auth/studioAuth.ts +34 -7
  29. package/src/auth/studioModeAuth.ts +2 -1
  30. package/src/auth/subscribeToStateAndFetchCurrentUser.test.ts +10 -2
  31. package/src/auth/subscribeToStateAndFetchCurrentUser.ts +5 -1
  32. package/src/auth/subscribeToStorageEventsAndSetToken.ts +2 -2
  33. package/src/auth/utils.ts +33 -0
  34. package/src/client/clientStore.test.ts +44 -30
  35. package/src/client/clientStore.ts +49 -48
  36. package/src/comlink/controller/actions/getOrCreateChannel.ts +2 -2
  37. package/src/comlink/node/actions/getOrCreateNode.ts +2 -2
  38. package/src/comlink/node/getNodeState.ts +2 -1
  39. package/src/config/sanityConfig.ts +78 -12
  40. package/src/document/actions.ts +18 -11
  41. package/src/document/applyDocumentActions.test.ts +7 -6
  42. package/src/document/applyDocumentActions.ts +10 -4
  43. package/src/document/documentStore.test.ts +542 -188
  44. package/src/document/documentStore.ts +142 -76
  45. package/src/document/events.ts +7 -2
  46. package/src/document/permissions.test.ts +18 -16
  47. package/src/document/permissions.ts +35 -11
  48. package/src/document/processActions.test.ts +359 -32
  49. package/src/document/processActions.ts +106 -78
  50. package/src/document/reducers.test.ts +117 -29
  51. package/src/document/reducers.ts +47 -40
  52. package/src/document/sharedListener.ts +16 -6
  53. package/src/document/util.ts +14 -0
  54. package/src/favorites/favorites.test.ts +9 -2
  55. package/src/presence/bifurTransport.test.ts +46 -6
  56. package/src/presence/bifurTransport.ts +19 -2
  57. package/src/presence/presenceStore.test.ts +96 -0
  58. package/src/presence/presenceStore.ts +96 -24
  59. package/src/preview/getPreviewState.test.ts +115 -98
  60. package/src/preview/getPreviewState.ts +38 -60
  61. package/src/preview/previewProjectionUtils.test.ts +179 -0
  62. package/src/preview/previewProjectionUtils.ts +93 -0
  63. package/src/preview/resolvePreview.test.ts +42 -25
  64. package/src/preview/resolvePreview.ts +33 -10
  65. package/src/preview/{previewStore.ts → types.ts} +8 -17
  66. package/src/projection/getProjectionState.test.ts +16 -16
  67. package/src/projection/getProjectionState.ts +6 -5
  68. package/src/projection/projectionQuery.ts +2 -3
  69. package/src/projection/projectionStore.test.ts +2 -2
  70. package/src/projection/resolveProjection.ts +2 -2
  71. package/src/projection/subscribeToStateAndFetchBatches.test.ts +1 -1
  72. package/src/projection/subscribeToStateAndFetchBatches.ts +12 -11
  73. package/src/projection/types.ts +1 -1
  74. package/src/query/queryStore.test.ts +12 -12
  75. package/src/query/queryStore.ts +12 -11
  76. package/src/query/reducers.ts +3 -3
  77. package/src/releases/getPerspectiveState.ts +7 -6
  78. package/src/releases/releasesStore.test.ts +20 -5
  79. package/src/releases/releasesStore.ts +20 -8
  80. package/src/store/createActionBinder.test.ts +31 -31
  81. package/src/store/createActionBinder.ts +43 -38
  82. package/src/store/createSanityInstance.ts +2 -3
  83. package/src/store/createStateSourceAction.test.ts +62 -0
  84. package/src/store/createStateSourceAction.ts +34 -39
  85. package/src/telemetry/__telemetry__/sdk.telemetry.ts +42 -0
  86. package/src/telemetry/devMode.test.ts +52 -0
  87. package/src/telemetry/devMode.ts +40 -0
  88. package/src/telemetry/initTelemetry.test.ts +225 -0
  89. package/src/telemetry/initTelemetry.ts +205 -0
  90. package/src/telemetry/telemetryManager.test.ts +263 -0
  91. package/src/telemetry/telemetryManager.ts +187 -0
  92. package/src/users/reducers.ts +3 -4
  93. package/src/users/usersStore.test.ts +1 -0
  94. package/src/users/usersStore.ts +5 -1
  95. package/src/utils/createFetcherStore.test.ts +6 -4
  96. package/src/utils/createFetcherStore.ts +8 -5
  97. package/src/utils/getStagingApiHost.test.ts +21 -0
  98. package/src/utils/getStagingApiHost.ts +14 -0
  99. package/src/utils/ids.test.ts +1 -29
  100. package/src/utils/ids.ts +0 -10
  101. package/src/utils/isImportError.test.ts +72 -0
  102. package/src/utils/isImportError.ts +34 -0
  103. package/src/utils/object.test.ts +95 -0
  104. package/src/utils/object.ts +142 -0
  105. package/src/utils/setCleanupTimeout.ts +24 -0
  106. package/src/preview/previewQuery.test.ts +0 -236
  107. package/src/preview/previewQuery.ts +0 -153
  108. package/src/preview/previewStore.test.ts +0 -36
  109. package/src/preview/subscribeToStateAndFetchBatches.test.ts +0 -221
  110. package/src/preview/subscribeToStateAndFetchBatches.ts +0 -112
  111. package/src/preview/util.ts +0 -13
@@ -9,7 +9,7 @@ import {refreshStampedToken} from './refreshStampedToken'
9
9
  import {checkForCookieAuth, getStudioTokenFromLocalStorage} from './studioModeAuth'
10
10
  import {subscribeToStateAndFetchCurrentUser} from './subscribeToStateAndFetchCurrentUser'
11
11
  import {subscribeToStorageEventsAndSetToken} from './subscribeToStorageEventsAndSetToken'
12
- import {getDefaultStorage} from './utils'
12
+ import {createLoggedInAuthState, getDefaultStorage} from './utils'
13
13
 
14
14
  /**
15
15
  * Resolves the initial auth state for Studio mode.
@@ -55,7 +55,7 @@ export function getStudioInitialState(options: AuthStrategyOptions): AuthStrateg
55
55
 
56
56
  if (providedToken) {
57
57
  return {
58
- authState: {type: AuthStateType.LOGGED_IN, token: providedToken, currentUser: null},
58
+ authState: createLoggedInAuthState(providedToken, null),
59
59
  storageKey: studioStorageKey,
60
60
  storageArea,
61
61
  authMethod,
@@ -65,7 +65,7 @@ export function getStudioInitialState(options: AuthStrategyOptions): AuthStrateg
65
65
 
66
66
  if (token) {
67
67
  return {
68
- authState: {type: AuthStateType.LOGGED_IN, token, currentUser: null},
68
+ authState: createLoggedInAuthState(token, null),
69
69
  storageKey: studioStorageKey,
70
70
  storageArea,
71
71
  authMethod: 'localstorage',
@@ -115,14 +115,29 @@ export function initializeStudioAuth(
115
115
 
116
116
  /**
117
117
  * Subscribe to a reactive token source from the Studio workspace.
118
- * The Studio is the single authority for auth — the SDK does not run
119
- * its own token refresher or cookie auth checks.
118
+ *
119
+ * When the token source emits a non-null token, the SDK uses it directly.
120
+ * When it emits `null`, the behavior depends on the `authenticated` flag
121
+ * from the Studio's workspace config:
122
+ *
123
+ * - `authenticated: true` — the Studio has already verified the user is
124
+ * logged in (e.g. via cookie auth). The SDK treats the null token as
125
+ * cookie-based auth and stays in the LOGGED_IN state.
126
+ *
127
+ * - `authenticated` absent/false — the user is genuinely not authenticated;
128
+ * transition to LOGGED_OUT.
129
+ *
130
+ * No async cookie probing is needed here because this code path only runs
131
+ * when a Studio provides SDKStudioContext, and the Studio's Workspace type
132
+ * always includes `authenticated`. The async `checkForCookieAuth` fallback
133
+ * remains in `initializeWithFallback` for the non-Studio path.
120
134
  */
121
135
  function initializeWithTokenSource(
122
136
  context: StoreContext<AuthStoreState>,
123
137
  tokenSource: TokenSource,
124
138
  ): {dispose: () => void; tokenRefresherStarted: boolean} {
125
139
  const subscriptions: Subscription[] = []
140
+ const studioAuthenticated = context.instance.config.studio?.authenticated === true
126
141
 
127
142
  // Subscribe to the current user fetcher — runs whenever auth state changes
128
143
  subscriptions.push(subscribeToStateAndFetchCurrentUser(context, {useProjectHostname: true}))
@@ -132,11 +147,23 @@ function initializeWithTokenSource(
132
147
  next: (token) => {
133
148
  const {state} = context
134
149
  if (token) {
150
+ // Studio provided a real token — use it directly
135
151
  state.set('studioTokenSource', (prev) => ({
136
152
  options: {...prev.options, authMethod: undefined},
137
- authState: {type: AuthStateType.LOGGED_IN, token, currentUser: null},
153
+ authState: createLoggedInAuthState(token, null),
154
+ }))
155
+ } else if (studioAuthenticated) {
156
+ // The Studio says the user is authenticated — null token means
157
+ // cookie-based auth is in use. Stay logged in with cookie method.
158
+ state.set('studioTokenSourceCookieAuth', (prev) => ({
159
+ options: {...prev.options, authMethod: 'cookie'},
160
+ authState:
161
+ prev.authState.type === AuthStateType.LOGGED_IN
162
+ ? prev.authState
163
+ : createLoggedInAuthState('', null),
138
164
  }))
139
165
  } else {
166
+ // No token and Studio doesn't confirm authentication — logged out
140
167
  state.set('studioTokenSourceLoggedOut', (prev) => ({
141
168
  options: {...prev.options, authMethod: undefined},
142
169
  authState: {type: AuthStateType.LOGGED_OUT, isDestroyingSession: false},
@@ -193,7 +220,7 @@ function initializeWithFallback(
193
220
  authState:
194
221
  prev.authState.type === AuthStateType.LOGGED_IN
195
222
  ? prev.authState
196
- : {type: AuthStateType.LOGGED_IN, token: '', currentUser: null},
223
+ : createLoggedInAuthState('', null),
197
224
  }))
198
225
  })
199
226
  }
@@ -1,5 +1,6 @@
1
1
  import {type ClientConfig, type SanityClient} from '@sanity/client'
2
2
 
3
+ import {REQUEST_TAG_PREFIX} from './authConstants'
3
4
  import {getTokenFromStorage} from './utils'
4
5
 
5
6
  /**
@@ -25,7 +26,7 @@ export async function checkForCookieAuth(
25
26
  const client = clientFactory({
26
27
  projectId,
27
28
  useCdn: false,
28
- requestTagPrefix: 'sdk',
29
+ requestTagPrefix: REQUEST_TAG_PREFIX,
29
30
  timeout: COOKIE_AUTH_TIMEOUT_MS,
30
31
  })
31
32
  const user = await client.request({
@@ -40,7 +40,11 @@ describe('subscribeToStateAndFetchCurrentUser', () => {
40
40
  useProjectHostname: false,
41
41
  useCdn: false,
42
42
  })
43
- expect(mockRequest).toHaveBeenCalledWith({method: 'GET', uri: '/users/me'})
43
+ expect(mockRequest).toHaveBeenCalledWith({
44
+ method: 'GET',
45
+ uri: '/users/me',
46
+ tag: 'users.get-current',
47
+ })
44
48
 
45
49
  subscription.unsubscribe()
46
50
  })
@@ -76,7 +80,11 @@ describe('subscribeToStateAndFetchCurrentUser', () => {
76
80
  useProjectHostname: false,
77
81
  useCdn: false,
78
82
  })
79
- expect(mockRequest).toHaveBeenCalledWith({method: 'GET', uri: '/users/me'})
83
+ expect(mockRequest).toHaveBeenCalledWith({
84
+ method: 'GET',
85
+ uri: '/users/me',
86
+ tag: 'users.get-current',
87
+ })
80
88
 
81
89
  subscription.unsubscribe()
82
90
  })
@@ -60,7 +60,11 @@ export const subscribeToStateAndFetchCurrentUser = (
60
60
  }),
61
61
  ),
62
62
  switchMap((client) =>
63
- client.observable.request<CurrentUser>({uri: '/users/me', method: 'GET'}),
63
+ client.observable.request<CurrentUser>({
64
+ uri: '/users/me',
65
+ method: 'GET',
66
+ tag: 'users.get-current',
67
+ }),
64
68
  ),
65
69
  )
66
70
 
@@ -3,7 +3,7 @@ import {defer, distinctUntilChanged, filter, map, type Subscription} from 'rxjs'
3
3
  import {type StoreContext} from '../store/defineStore'
4
4
  import {AuthStateType} from './authStateType'
5
5
  import {type AuthStoreState} from './authStore'
6
- import {getStorageEvents, getTokenFromStorage} from './utils'
6
+ import {createLoggedInAuthState, getStorageEvents, getTokenFromStorage} from './utils'
7
7
 
8
8
  export const subscribeToStorageEventsAndSetToken = ({
9
9
  state,
@@ -22,7 +22,7 @@ export const subscribeToStorageEventsAndSetToken = ({
22
22
  return tokenFromStorage$.subscribe((token) => {
23
23
  state.set('updateTokenFromStorageEvent', {
24
24
  authState: token
25
- ? {type: AuthStateType.LOGGED_IN, token, currentUser: null}
25
+ ? createLoggedInAuthState(token, null)
26
26
  : {type: AuthStateType.LOGGED_OUT, isDestroyingSession: false},
27
27
  })
28
28
  })
package/src/auth/utils.ts CHANGED
@@ -1,7 +1,40 @@
1
1
  import {type ClientError} from '@sanity/client'
2
+ import {type CurrentUser} from '@sanity/types'
2
3
  import {EMPTY, fromEvent, Observable} from 'rxjs'
3
4
 
4
5
  import {AUTH_CODE_PARAM, DEFAULT_BASE} from './authConstants'
6
+ import {AuthStateType} from './authStateType'
7
+ import {type LoggedInAuthState} from './authStore'
8
+
9
+ /**
10
+ * Creates a properly initialized {@link LoggedInAuthState}.
11
+ *
12
+ * For stamped tokens (containing `"-st"`), `lastTokenRefresh` is set to
13
+ * `Date.now()` so that the visibility-change handler in
14
+ * {@link refreshStampedToken} does not trigger an unnecessary refresh the
15
+ * first time the tab becomes visible.
16
+ *
17
+ * @param token - The auth token.
18
+ * @param currentUser - The current user, or `null` if not yet fetched.
19
+ * @param existingLastTokenRefresh - An existing timestamp to preserve
20
+ * (e.g. when updating a token while keeping the previous refresh time).
21
+ * @internal
22
+ */
23
+ export function createLoggedInAuthState(
24
+ token: string,
25
+ currentUser: CurrentUser | null,
26
+ existingLastTokenRefresh?: number,
27
+ ): LoggedInAuthState {
28
+ const isStampedToken = token.includes('-st')
29
+ const lastTokenRefresh = existingLastTokenRefresh ?? (isStampedToken ? Date.now() : undefined)
30
+
31
+ return {
32
+ type: AuthStateType.LOGGED_IN,
33
+ token,
34
+ currentUser,
35
+ ...(lastTokenRefresh !== undefined && {lastTokenRefresh}),
36
+ }
37
+ }
5
38
 
6
39
  export function getAuthCode(callbackUrl: string | undefined, locationHref: string): string | null {
7
40
  const loc = new URL(locationHref, DEFAULT_BASE)
@@ -68,6 +68,20 @@ describe('clientStore', () => {
68
68
  })
69
69
  })
70
70
 
71
+ it('should pass staging apiHost when __SANITY_STAGING__ is true and no explicit apiHost', () => {
72
+ vi.stubGlobal('__SANITY_STAGING__', true)
73
+
74
+ getClient(instance, {apiVersion: '2024-11-12'})
75
+
76
+ expect(vi.mocked(createClient)).toHaveBeenCalledWith(
77
+ expect.objectContaining({
78
+ apiHost: 'https://api.sanity.work',
79
+ }),
80
+ )
81
+
82
+ vi.unstubAllGlobals()
83
+ })
84
+
71
85
  it('should throw when using disallowed configuration keys', () => {
72
86
  expect(() =>
73
87
  getClient(instance, {
@@ -176,17 +190,17 @@ describe('clientStore', () => {
176
190
  })
177
191
  })
178
192
 
179
- describe('source handling', () => {
180
- it('should create client when source is provided', () => {
193
+ describe('resource handling', () => {
194
+ it('should create client when resource is provided', () => {
181
195
  const client = getClient(instance, {
182
196
  apiVersion: '2024-11-12',
183
- source: {projectId: 'source-project', dataset: 'source-dataset'},
197
+ resource: {projectId: 'source-project', dataset: 'source-dataset'},
184
198
  })
185
199
 
186
200
  expect(vi.mocked(createClient)).toHaveBeenCalledWith(
187
201
  expect.objectContaining({
188
- 'apiVersion': '2024-11-12',
189
- '~experimental_resource': {type: 'dataset', id: 'source-project.source-dataset'},
202
+ apiVersion: '2024-11-12',
203
+ resource: {type: 'dataset', id: 'source-project.source-dataset'},
190
204
  }),
191
205
  )
192
206
  // Client should be projectless - no projectId/dataset in config
@@ -194,21 +208,21 @@ describe('clientStore', () => {
194
208
  expect(client.config()).not.toHaveProperty('dataset')
195
209
  expect(client.config()).toEqual(
196
210
  expect.objectContaining({
197
- '~experimental_resource': {type: 'dataset', id: 'source-project.source-dataset'},
211
+ resource: {type: 'dataset', id: 'source-project.source-dataset'},
198
212
  }),
199
213
  )
200
214
  })
201
215
 
202
- it('should create resource when source has array sourceId and be projectless', () => {
216
+ it('should create resource when resource has array resourceId and be projectless', () => {
203
217
  const client = getClient(instance, {
204
218
  apiVersion: '2024-11-12',
205
- source: {mediaLibraryId: 'media-lib-123'},
219
+ resource: {mediaLibraryId: 'media-lib-123'},
206
220
  })
207
221
 
208
222
  expect(vi.mocked(createClient)).toHaveBeenCalledWith(
209
223
  expect.objectContaining({
210
- '~experimental_resource': {type: 'media-library', id: 'media-lib-123'},
211
- 'apiVersion': '2024-11-12',
224
+ resource: {type: 'media-library', id: 'media-lib-123'},
225
+ apiVersion: '2024-11-12',
212
226
  }),
213
227
  )
214
228
  // Client should be projectless - no projectId/dataset in config
@@ -216,21 +230,21 @@ describe('clientStore', () => {
216
230
  expect(client.config()).not.toHaveProperty('dataset')
217
231
  expect(client.config()).toEqual(
218
232
  expect.objectContaining({
219
- '~experimental_resource': {type: 'media-library', id: 'media-lib-123'},
233
+ resource: {type: 'media-library', id: 'media-lib-123'},
220
234
  }),
221
235
  )
222
236
  })
223
237
 
224
- it('should create resource when canvas source is provided and be projectless', () => {
238
+ it('should create resource when canvas resource is provided and be projectless', () => {
225
239
  const client = getClient(instance, {
226
240
  apiVersion: '2024-11-12',
227
- source: {canvasId: 'canvas-123'},
241
+ resource: {canvasId: 'canvas-123'},
228
242
  })
229
243
 
230
244
  expect(vi.mocked(createClient)).toHaveBeenCalledWith(
231
245
  expect.objectContaining({
232
- '~experimental_resource': {type: 'canvas', id: 'canvas-123'},
233
- 'apiVersion': '2024-11-12',
246
+ resource: {type: 'canvas', id: 'canvas-123'},
247
+ apiVersion: '2024-11-12',
234
248
  }),
235
249
  )
236
250
  // Client should be projectless - no projectId/dataset in config
@@ -238,38 +252,38 @@ describe('clientStore', () => {
238
252
  expect(client.config()).not.toHaveProperty('dataset')
239
253
  expect(client.config()).toEqual(
240
254
  expect.objectContaining({
241
- '~experimental_resource': {type: 'canvas', id: 'canvas-123'},
255
+ resource: {type: 'canvas', id: 'canvas-123'},
242
256
  }),
243
257
  )
244
258
  })
245
259
 
246
- it('should create projectless client when source is provided, ignoring instance config', () => {
260
+ it('should create projectless client when resource is provided, ignoring instance config', () => {
247
261
  const client = getClient(instance, {
248
262
  apiVersion: '2024-11-12',
249
- source: {projectId: 'source-project', dataset: 'source-dataset'},
263
+ resource: {projectId: 'source-project', dataset: 'source-dataset'},
250
264
  })
251
265
 
252
- // Client should be projectless - source takes precedence, instance config is ignored
266
+ // Client should be projectless - resource takes precedence, instance config is ignored
253
267
  expect(client.config()).not.toHaveProperty('projectId')
254
268
  expect(client.config()).not.toHaveProperty('dataset')
255
269
  expect(client.config()).toEqual(
256
270
  expect.objectContaining({
257
- '~experimental_resource': {type: 'dataset', id: 'source-project.source-dataset'},
271
+ resource: {type: 'dataset', id: 'source-project.source-dataset'},
258
272
  }),
259
273
  )
260
274
  })
261
275
 
262
- it('should warn when both source and explicit projectId/dataset are provided', () => {
276
+ it('should warn when both resource and explicit projectId/dataset are provided', () => {
263
277
  const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
264
278
  const client = getClient(instance, {
265
279
  apiVersion: '2024-11-12',
266
- source: {projectId: 'source-project', dataset: 'source-dataset'},
280
+ resource: {projectId: 'source-project', dataset: 'source-dataset'},
267
281
  projectId: 'explicit-project',
268
282
  dataset: 'explicit-dataset',
269
283
  })
270
284
 
271
285
  expect(consoleSpy).toHaveBeenCalledWith(
272
- 'Both source and explicit projectId/dataset are provided. The source will be used and projectId/dataset will be ignored.',
286
+ 'Both resource and explicit projectId/dataset are provided. The resource will be used and projectId/dataset will be ignored.',
273
287
  )
274
288
  // Client should still be projectless despite explicit projectId/dataset
275
289
  expect(client.config()).not.toHaveProperty('projectId')
@@ -277,18 +291,18 @@ describe('clientStore', () => {
277
291
  consoleSpy.mockRestore()
278
292
  })
279
293
 
280
- it('should create different clients for different sources', () => {
294
+ it('should create different clients for different resources', () => {
281
295
  const client1 = getClient(instance, {
282
296
  apiVersion: '2024-11-12',
283
- source: {projectId: 'source-project', dataset: 'source-dataset'},
297
+ resource: {projectId: 'source-project', dataset: 'source-dataset'},
284
298
  })
285
299
  const client2 = getClient(instance, {
286
300
  apiVersion: '2024-11-12',
287
- source: {mediaLibraryId: 'media-lib-123'},
301
+ resource: {mediaLibraryId: 'media-lib-123'},
288
302
  })
289
303
  const client3 = getClient(instance, {
290
304
  apiVersion: '2024-11-12',
291
- source: {canvasId: 'canvas-123'},
305
+ resource: {canvasId: 'canvas-123'},
292
306
  })
293
307
 
294
308
  expect(client1).not.toBe(client2)
@@ -297,14 +311,14 @@ describe('clientStore', () => {
297
311
  expect(vi.mocked(createClient)).toHaveBeenCalledTimes(3)
298
312
  })
299
313
 
300
- it('should reuse clients with identical source configurations', () => {
314
+ it('should reuse clients with identical resource configurations', () => {
301
315
  const client1 = getClient(instance, {
302
316
  apiVersion: '2024-11-12',
303
- source: {projectId: 'source-project', dataset: 'source-dataset'},
317
+ resource: {projectId: 'source-project', dataset: 'source-dataset'},
304
318
  })
305
319
  const client2 = getClient(instance, {
306
320
  apiVersion: '2024-11-12',
307
- source: {projectId: 'source-project', dataset: 'source-dataset'},
321
+ resource: {projectId: 'source-project', dataset: 'source-dataset'},
308
322
  })
309
323
 
310
324
  expect(client1).toBe(client2)
@@ -1,16 +1,17 @@
1
1
  import {type ClientConfig, createClient, type SanityClient} from '@sanity/client'
2
- import {pick} from 'lodash-es'
3
2
 
4
3
  import {getAuthMethodState, getTokenState} from '../auth/authStore'
5
4
  import {
6
- type DocumentSource,
7
- isCanvasSource,
8
- isDatasetSource,
9
- isMediaLibrarySource,
5
+ type DocumentResource,
6
+ isCanvasResource,
7
+ isDatasetResource,
8
+ isMediaLibraryResource,
10
9
  } from '../config/sanityConfig'
11
10
  import {bindActionGlobally} from '../store/createActionBinder'
12
11
  import {createStateSourceAction} from '../store/createStateSourceAction'
13
12
  import {defineStore, type StoreContext} from '../store/defineStore'
13
+ import {getStagingApiHost} from '../utils/getStagingApiHost'
14
+ import {pickProperties} from '../utils/object'
14
15
 
15
16
  const DEFAULT_API_VERSION = '2024-11-12'
16
17
  const DEFAULT_REQUEST_TAG_PREFIX = 'sanity.sdk'
@@ -30,22 +31,21 @@ type AllowedClientConfigKey =
30
31
  | 'useProjectHostname'
31
32
 
32
33
  const allowedKeys = Object.keys({
33
- 'apiHost': null,
34
- 'useCdn': null,
35
- 'token': null,
36
- 'perspective': null,
37
- 'proxy': null,
38
- 'withCredentials': null,
39
- 'timeout': null,
40
- 'maxRetries': null,
41
- 'dataset': null,
42
- 'projectId': null,
43
- 'scope': null,
44
- 'apiVersion': null,
45
- 'requestTagPrefix': null,
46
- 'useProjectHostname': null,
47
- '~experimental_resource': null,
48
- 'source': null,
34
+ apiHost: null,
35
+ useCdn: null,
36
+ token: null,
37
+ perspective: null,
38
+ proxy: null,
39
+ withCredentials: null,
40
+ timeout: null,
41
+ maxRetries: null,
42
+ dataset: null,
43
+ projectId: null,
44
+ scope: null,
45
+ apiVersion: null,
46
+ requestTagPrefix: null,
47
+ useProjectHostname: null,
48
+ resource: null,
49
49
  } satisfies Record<keyof ClientOptions, null>) as (keyof ClientOptions)[]
50
50
 
51
51
  const DEFAULT_CLIENT_CONFIG: ClientConfig = {
@@ -66,11 +66,6 @@ export interface ClientStoreState {
66
66
  authMethod?: 'localstorage' | 'cookie'
67
67
  }
68
68
 
69
- interface ClientResource {
70
- type: 'dataset' | 'media-library' | 'canvas'
71
- id: string
72
- }
73
-
74
69
  /**
75
70
  * Options used when retrieving a client instance from the client store.
76
71
  *
@@ -93,20 +88,17 @@ export interface ClientOptions extends Pick<ClientConfig, AllowedClientConfigKey
93
88
  * and the global client ('global'). When set to `'global'`, the global client
94
89
  * is used.
95
90
  */
96
- 'scope'?: 'default' | 'global'
91
+ scope?: 'default' | 'global'
97
92
  /**
98
93
  * A required string indicating the API version for the client.
99
94
  */
100
- 'apiVersion': string
101
- /**
102
- * @internal
103
- */
104
- '~experimental_resource'?: ClientConfig['~experimental_resource']
95
+ apiVersion: string
105
96
 
106
97
  /**
107
98
  * @internal
99
+ * The SDK resource to use for the client -- this will get transformed into a ClientConfig resource.
108
100
  */
109
- 'source'?: DocumentSource
101
+ resource?: DocumentResource
110
102
  }
111
103
 
112
104
  const clientStore = defineStore<ClientStoreState>({
@@ -143,7 +135,13 @@ const listenToAuthMethod = ({instance, state}: StoreContext<ClientStoreState>) =
143
135
  })
144
136
  }
145
137
 
146
- const getClientConfigKey = (options: ClientOptions) => JSON.stringify(pick(options, ...allowedKeys))
138
+ type ClientInstanceCacheKeyInput = ClientConfig &
139
+ Partial<Pick<ClientOptions, 'scope'>> & {
140
+ apiVersion: string
141
+ }
142
+
143
+ const getClientConfigKey = (options: ClientInstanceCacheKeyInput) =>
144
+ JSON.stringify(pickProperties(options, allowedKeys))
147
145
 
148
146
  /**
149
147
  * Retrieves a Sanity client instance configured with the provided options.
@@ -181,41 +179,44 @@ export const getClient = bindActionGlobally(
181
179
  const tokenFromState = state.get().token
182
180
  const {clients, authMethod} = state.get()
183
181
 
184
- let resource: ClientResource | undefined
182
+ let resource: ClientConfig['resource'] | undefined
185
183
 
186
- if (options.source) {
187
- if (isDatasetSource(options.source)) {
188
- resource = {type: 'dataset', id: `${options.source.projectId}.${options.source.dataset}`}
189
- } else if (isMediaLibrarySource(options.source)) {
190
- resource = {type: 'media-library', id: options.source.mediaLibraryId}
191
- } else if (isCanvasSource(options.source)) {
192
- resource = {type: 'canvas', id: options.source.canvasId}
184
+ if (options.resource) {
185
+ if (isDatasetResource(options.resource)) {
186
+ resource = {
187
+ type: 'dataset',
188
+ id: `${options.resource.projectId}.${options.resource.dataset}`,
189
+ }
190
+ } else if (isMediaLibraryResource(options.resource)) {
191
+ resource = {type: 'media-library', id: options.resource.mediaLibraryId}
192
+ } else if (isCanvasResource(options.resource)) {
193
+ resource = {type: 'canvas', id: options.resource.canvasId}
193
194
  }
194
195
  }
195
196
 
196
197
  const projectId = options.projectId ?? instance.config.projectId
197
198
  const dataset = options.dataset ?? instance.config.dataset
198
- const apiHost = options.apiHost ?? instance.config.auth?.apiHost
199
+ const apiHost = options.apiHost ?? instance.config.auth?.apiHost ?? getStagingApiHost()
199
200
 
200
- const effectiveOptions: ClientOptions = {
201
+ const effectiveOptions: ClientConfig & {apiVersion: string} = {
201
202
  ...DEFAULT_CLIENT_CONFIG,
202
203
  ...((options.scope === 'global' || !projectId || resource) && {useProjectHostname: false}),
203
204
  token: authMethod === 'cookie' ? undefined : (tokenFromState ?? undefined),
204
205
  ...options,
205
206
  ...(projectId && {projectId}),
206
207
  ...(dataset && {dataset}),
208
+ ...(resource ? {resource} : {resource: undefined}),
207
209
  ...(apiHost && {apiHost}),
208
- ...(resource && {'~experimental_resource': resource}),
209
210
  }
210
211
 
211
- // When a source is provided, don't use projectId/dataset - the client should be "projectless"
212
- // The client code itself will ignore the non-source config, so we do this to prevent confusing the user.
212
+ // When a resource is provided, don't use projectId/dataset - the client should be "projectless"
213
+ // The client code itself will ignore the non-resource config, so we do this to prevent confusing the user.
213
214
  // (ref: https://github.com/sanity-io/client/blob/5c23f81f5ab93a53f5b22b39845c867988508d84/src/data/dataMethods.ts#L691)
214
215
  if (resource) {
215
216
  if (options.projectId || options.dataset) {
216
217
  // eslint-disable-next-line no-console
217
218
  console.warn(
218
- 'Both source and explicit projectId/dataset are provided. The source will be used and projectId/dataset will be ignored.',
219
+ 'Both resource and explicit projectId/dataset are provided. The resource will be used and projectId/dataset will be ignored.',
219
220
  )
220
221
  }
221
222
  delete effectiveOptions.projectId
@@ -1,7 +1,7 @@
1
1
  import {type ChannelInput, type ChannelInstance} from '@sanity/comlink'
2
- import {isEqual} from 'lodash-es'
3
2
 
4
3
  import {type StoreContext} from '../../../store/defineStore'
4
+ import {isDeepEqual} from '../../../utils/object'
5
5
  import {type FrameMessage, type WindowMessage} from '../../types'
6
6
  import {type ComlinkControllerState} from '../comlinkControllerStore'
7
7
 
@@ -25,7 +25,7 @@ export const getOrCreateChannel = (
25
25
 
26
26
  // limit channels to one per name
27
27
  if (existing) {
28
- if (!isEqual(existing.options, options)) {
28
+ if (!isDeepEqual(existing.options, options)) {
29
29
  throw new Error(`Channel "${options.name}" already exists with different options`)
30
30
  }
31
31
 
@@ -1,7 +1,7 @@
1
1
  import {createNode, type Node, type NodeInput} from '@sanity/comlink'
2
- import {isEqual} from 'lodash-es'
3
2
 
4
3
  import {type StoreContext} from '../../../store/defineStore'
4
+ import {isDeepEqual} from '../../../utils/object'
5
5
  import {type FrameMessage, type WindowMessage} from '../../types'
6
6
  import {type ComlinkNodeState} from '../comlinkNodeStore'
7
7
 
@@ -14,7 +14,7 @@ export const getOrCreateNode = (
14
14
 
15
15
  // limit nodes to one per name
16
16
  if (existing) {
17
- if (!isEqual(existing.options, options)) {
17
+ if (!isDeepEqual(existing.options, options)) {
18
18
  throw new Error(`Node "${options.name}" already exists with different options`)
19
19
  }
20
20
 
@@ -3,6 +3,7 @@ import {createSelector} from 'reselect'
3
3
 
4
4
  import {bindActionGlobally} from '../../store/createActionBinder'
5
5
  import {createStateSourceAction, type SelectorContext} from '../../store/createStateSourceAction'
6
+ import {setCleanupTimeout} from '../../utils/setCleanupTimeout'
6
7
  import {type FrameMessage, type WindowMessage} from '../types'
7
8
  import {
8
9
  type ComlinkNodeState,
@@ -57,7 +58,7 @@ export const getNodeState = bindActionGlobally(
57
58
  subs.add(subscriberId)
58
59
 
59
60
  return () => {
60
- setTimeout(() => {
61
+ setCleanupTimeout(() => {
61
62
  const activeSubs = state.get().subscriptions.get(nodeName)
62
63
  if (activeSubs) {
63
64
  activeSubs.delete(subscriberId)