@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sanity/sdk",
3
- "version": "2.8.0",
3
+ "version": "2.10.0",
4
4
  "private": false,
5
5
  "description": "Sanity SDK",
6
6
  "keywords": [
@@ -31,7 +31,12 @@
31
31
  "import": "./dist/index.js",
32
32
  "default": "./dist/index.js"
33
33
  },
34
- "./package.json": "./package.json"
34
+ "./package.json": "./package.json",
35
+ "./_internal": {
36
+ "source": "./src/_exports/_internal.ts",
37
+ "import": "./dist/_exports/_internal.js",
38
+ "default": "./dist/_exports/_internal.js"
39
+ }
35
40
  },
36
41
  "main": "./dist/index.js",
37
42
  "module": "./dist/index.js",
@@ -44,39 +49,39 @@
44
49
  "prettier": "@sanity/prettier-config",
45
50
  "dependencies": {
46
51
  "@sanity/bifur-client": "^0.4.1",
47
- "@sanity/client": "^7.14.1",
48
- "@sanity/comlink": "^3.0.4",
52
+ "@sanity/client": "^7.22.0",
53
+ "@sanity/comlink": "^3.1.1",
49
54
  "@sanity/diff-match-patch": "^3.2.0",
50
55
  "@sanity/diff-patch": "^6.0.0",
51
56
  "@sanity/id-utils": "^1.0.0",
57
+ "@sanity/image-url": "^2.0.3",
52
58
  "@sanity/json-match": "^1.0.5",
53
- "@sanity/message-protocol": "^0.18.0",
54
- "@sanity/mutate": "^0.12.4",
59
+ "@sanity/message-protocol": "^0.23.0",
60
+ "@sanity/mutate": "^0.16.1",
61
+ "@sanity/telemetry": "^1.1.0",
55
62
  "@sanity/types": "^5.2.0",
56
63
  "groq": "3.88.1-typegen-experimental.0",
57
64
  "groq-js": "^1.19.0",
58
- "lodash-es": "^4.17.21",
59
65
  "reselect": "^5.1.1",
60
66
  "rxjs": "^7.8.2",
61
- "zustand": "^5.0.4"
67
+ "zustand": "^5.0.12"
62
68
  },
63
69
  "devDependencies": {
64
70
  "@sanity/browserslist-config": "^1.0.5",
65
71
  "@sanity/pkg-utils": "^8.1.29",
66
72
  "@sanity/prettier-config": "^1.0.6",
67
- "@types/lodash-es": "^4.17.12",
68
73
  "@vitest/coverage-v8": "3.2.4",
69
74
  "eslint": "^9.22.0",
70
75
  "prettier": "^3.7.3",
71
76
  "rollup-plugin-visualizer": "^5.14.0",
72
77
  "typescript": "^5.8.3",
73
- "vite": "^6.3.4",
78
+ "vite": "^7.0.0",
74
79
  "vitest": "^3.2.4",
75
80
  "@repo/config-eslint": "0.0.0",
76
- "@repo/config-test": "0.0.1",
81
+ "@repo/tsconfig": "0.0.1",
77
82
  "@repo/package.bundle": "3.82.0",
78
83
  "@repo/package.config": "0.0.1",
79
- "@repo/tsconfig": "0.0.1"
84
+ "@repo/config-test": "0.0.1"
80
85
  },
81
86
  "engines": {
82
87
  "node": ">=20.19"
@@ -0,0 +1,14 @@
1
+ export {isStudioConfig} from '../auth/authMode'
2
+ export {
3
+ type ApiErrorBody,
4
+ getClientErrorApiBody,
5
+ getClientErrorApiDescription,
6
+ getClientErrorApiType,
7
+ isProjectUserNotFoundClientError,
8
+ } from '../auth/utils'
9
+ export {PREVIEW_PROJECTION} from '../preview/previewConstants'
10
+ export {transformProjectionToPreview} from '../preview/previewProjectionUtils'
11
+ export {getQueryKey, parseQueryKey} from '../query/queryStore' // only used for memoizing in React, not needed for actual functionality
12
+ export {getTelemetryManager, initTelemetry, trackHookMounted} from '../telemetry/initTelemetry'
13
+ export {getUsersKey, parseUsersKey} from '../users/reducers' // only used for memoizing in React, not needed for actual functionality
14
+ export {createGroqSearchFilter} from '../utils/createGroqSearchFilter'
@@ -86,15 +86,22 @@ export {
86
86
  type LogNamespace,
87
87
  } from '../config/loggingConfig'
88
88
  export {
89
+ type CanvasResource,
89
90
  type CanvasSource,
90
91
  type DatasetHandle,
92
+ type DatasetResource,
91
93
  type DatasetSource,
92
94
  type DocumentHandle,
95
+ type DocumentResource,
93
96
  type DocumentSource,
94
97
  type DocumentTypeHandle,
98
+ isCanvasResource,
95
99
  isCanvasSource,
100
+ isDatasetResource,
96
101
  isDatasetSource,
102
+ isMediaLibraryResource,
97
103
  isMediaLibrarySource,
104
+ type MediaLibraryResource,
98
105
  type MediaLibrarySource,
99
106
  type PerspectiveHandle,
100
107
  type ProjectHandle,
@@ -141,6 +148,7 @@ export {
141
148
  type DocumentEditedEvent,
142
149
  type DocumentEvent,
143
150
  type DocumentPublishedEvent,
151
+ type DocumentTransactionSubmissionResult,
144
152
  type DocumentUnpublishedEvent,
145
153
  type TransactionAcceptedEvent,
146
154
  type TransactionRevertedEvent,
@@ -159,8 +167,16 @@ export type {
159
167
  UserPresence,
160
168
  } from '../presence/types'
161
169
  export {getPreviewState, type GetPreviewStateOptions} from '../preview/getPreviewState'
162
- export type {PreviewStoreState, PreviewValue, ValuePending} from '../preview/previewStore'
170
+ export {PREVIEW_PROJECTION} from '../preview/previewConstants'
171
+ export {transformProjectionToPreview} from '../preview/previewProjectionUtils'
163
172
  export {resolvePreview, type ResolvePreviewOptions} from '../preview/resolvePreview'
173
+ export type {
174
+ PreviewMedia,
175
+ PreviewQueryResult,
176
+ PreviewStoreState,
177
+ PreviewValue,
178
+ ValuePending,
179
+ } from '../preview/types'
164
180
  export {type OrgVerificationResult} from '../project/organizationVerification'
165
181
  export {getProjectState, resolveProject} from '../project/project'
166
182
  export {getProjectionState} from '../projection/getProjectionState'
@@ -203,6 +219,7 @@ export {type FetcherStore, type FetcherStoreState} from '../utils/createFetcherS
203
219
  export {createGroqSearchFilter} from '../utils/createGroqSearchFilter'
204
220
  export {defineIntent, type Intent, type IntentFilter} from '../utils/defineIntent'
205
221
  export {getCorsErrorProjectId} from '../utils/getCorsErrorProjectId'
222
+ export {isImportError} from '../utils/isImportError'
206
223
  export {CORE_SDK_VERSION} from '../version'
207
224
  export {
208
225
  getIndexForKey,
@@ -7,6 +7,7 @@ import {createSanityInstance} from '../store/createSanityInstance'
7
7
  import {AuthStateType} from './authStateType'
8
8
  import {
9
9
  authStore,
10
+ getAuthMethodState,
10
11
  getAuthState,
11
12
  getCurrentUserState,
12
13
  getDashboardOrganizationId,
@@ -17,7 +18,12 @@ import {handleAuthCallback} from './handleAuthCallback'
17
18
  import {checkForCookieAuth, getStudioTokenFromLocalStorage} from './studioModeAuth'
18
19
  import {subscribeToStateAndFetchCurrentUser} from './subscribeToStateAndFetchCurrentUser'
19
20
  import {subscribeToStorageEventsAndSetToken} from './subscribeToStorageEventsAndSetToken'
20
- import {getAuthCode, getTokenFromLocation, getTokenFromStorage} from './utils'
21
+ import {
22
+ createLoggedInAuthState,
23
+ getAuthCode,
24
+ getTokenFromLocation,
25
+ getTokenFromStorage,
26
+ } from './utils'
21
27
 
22
28
  vi.mock('./utils', async (importOriginal) => {
23
29
  const original = await importOriginal<typeof import('./utils')>()
@@ -67,6 +73,35 @@ describe('authStore', () => {
67
73
  instance?.dispose()
68
74
  })
69
75
 
76
+ it('uses staging apiHost when __SANITY_STAGING__ is true and no explicit apiHost', () => {
77
+ vi.stubGlobal('__SANITY_STAGING__', true)
78
+
79
+ instance = createSanityInstance({
80
+ projectId: 'p',
81
+ dataset: 'd',
82
+ })
83
+
84
+ const {options} = authStore.getInitialState(instance, null)
85
+ expect(options.apiHost).toBe('https://api.sanity.work')
86
+
87
+ vi.unstubAllGlobals()
88
+ })
89
+
90
+ it('prefers explicit apiHost over __SANITY_STAGING__', () => {
91
+ vi.stubGlobal('__SANITY_STAGING__', true)
92
+
93
+ instance = createSanityInstance({
94
+ projectId: 'p',
95
+ dataset: 'd',
96
+ auth: {apiHost: 'https://custom.host'},
97
+ })
98
+
99
+ const {options} = authStore.getInitialState(instance, null)
100
+ expect(options.apiHost).toBe('https://custom.host')
101
+
102
+ vi.unstubAllGlobals()
103
+ })
104
+
70
105
  it('sets initial options onto state', () => {
71
106
  const apiHost = 'test-api-host'
72
107
  const callbackUrl = '/login/callback'
@@ -368,6 +403,61 @@ describe('authStore', () => {
368
403
  expect(checkForCookieAuth).not.toHaveBeenCalled()
369
404
  })
370
405
 
406
+ it('treats null token as cookie auth when studio reports authenticated', () => {
407
+ let tokenObserver!: {next: (token: string | null) => void}
408
+ const mockSubscribe = vi.fn((observer: {next: (token: string | null) => void}) => {
409
+ tokenObserver = observer
410
+ return {unsubscribe: vi.fn()}
411
+ })
412
+ const mockTokenSource = {subscribe: mockSubscribe}
413
+
414
+ instance = createSanityInstance({
415
+ projectId: 'studio-project',
416
+ dataset: 'production',
417
+ studio: {
418
+ authenticated: true,
419
+ auth: {token: mockTokenSource},
420
+ },
421
+ })
422
+
423
+ getAuthState(instance)
424
+ tokenObserver.next(null)
425
+
426
+ expect(getAuthState(instance).getCurrent()).toMatchObject({
427
+ type: AuthStateType.LOGGED_IN,
428
+ token: '',
429
+ })
430
+ expect(getAuthMethodState(instance).getCurrent()).toBe('cookie')
431
+ expect(checkForCookieAuth).not.toHaveBeenCalled()
432
+ })
433
+
434
+ it('sets logged out when token is null and studio is not authenticated', () => {
435
+ let tokenObserver!: {next: (token: string | null) => void}
436
+ const mockSubscribe = vi.fn((observer: {next: (token: string | null) => void}) => {
437
+ tokenObserver = observer
438
+ return {unsubscribe: vi.fn()}
439
+ })
440
+ const mockTokenSource = {subscribe: mockSubscribe}
441
+
442
+ instance = createSanityInstance({
443
+ projectId: 'studio-project',
444
+ dataset: 'production',
445
+ studio: {
446
+ authenticated: false,
447
+ auth: {token: mockTokenSource},
448
+ },
449
+ })
450
+
451
+ getAuthState(instance)
452
+ tokenObserver.next(null)
453
+
454
+ expect(getAuthState(instance).getCurrent()).toMatchObject({
455
+ type: AuthStateType.LOGGED_OUT,
456
+ })
457
+ expect(getAuthMethodState(instance).getCurrent()).toBeUndefined()
458
+ expect(checkForCookieAuth).not.toHaveBeenCalled()
459
+ })
460
+
371
461
  it('falls back to default auth (storage token) when studio mode is disabled', () => {
372
462
  const storageToken = 'regular-storage-token'
373
463
  vi.mocked(getTokenFromStorage).mockReturnValue(storageToken)
@@ -673,4 +763,63 @@ describe('authStore', () => {
673
763
  expect(organizationId.getCurrent()).toBeUndefined()
674
764
  })
675
765
  })
766
+
767
+ describe('createLoggedInAuthState', () => {
768
+ beforeEach(() => {
769
+ vi.useFakeTimers()
770
+ vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z'))
771
+ })
772
+
773
+ afterEach(() => {
774
+ vi.useRealTimers()
775
+ })
776
+
777
+ it('sets lastTokenRefresh for stamped tokens', () => {
778
+ const state = createLoggedInAuthState('sk-stamped-token-st123', null)
779
+
780
+ expect(state).toEqual({
781
+ type: AuthStateType.LOGGED_IN,
782
+ token: 'sk-stamped-token-st123',
783
+ currentUser: null,
784
+ lastTokenRefresh: Date.now(),
785
+ })
786
+ })
787
+
788
+ it('does not set lastTokenRefresh for non-stamped tokens', () => {
789
+ const state = createLoggedInAuthState('sk-regular-token', null)
790
+
791
+ expect(state).toEqual({
792
+ type: AuthStateType.LOGGED_IN,
793
+ token: 'sk-regular-token',
794
+ currentUser: null,
795
+ })
796
+ expect(state.lastTokenRefresh).toBeUndefined()
797
+ })
798
+
799
+ it('preserves an existing lastTokenRefresh when provided', () => {
800
+ const existingTimestamp = Date.now() - 5000
801
+ const state = createLoggedInAuthState('sk-stamped-token-st123', null, existingTimestamp)
802
+
803
+ expect(state.lastTokenRefresh).toBe(existingTimestamp)
804
+ })
805
+
806
+ it('passes through the currentUser', () => {
807
+ const user = {id: 'user-1', name: 'Test'} as CurrentUser
808
+ const state = createLoggedInAuthState('sk-stamped-token-st123', user)
809
+
810
+ expect(state.currentUser).toBe(user)
811
+ })
812
+
813
+ it('handles null currentUser', () => {
814
+ const state = createLoggedInAuthState('sk-stamped-token-st123', null)
815
+
816
+ expect(state.currentUser).toBeNull()
817
+ })
818
+
819
+ it('does not set lastTokenRefresh when explicitly undefined for non-stamped token', () => {
820
+ const state = createLoggedInAuthState('sk-regular-token', null, undefined)
821
+
822
+ expect(state.lastTokenRefresh).toBeUndefined()
823
+ })
824
+ })
676
825
  })
@@ -5,13 +5,14 @@ import {type AuthConfig, type AuthProvider} from '../config/authConfig'
5
5
  import {bindActionGlobally} from '../store/createActionBinder'
6
6
  import {createStateSourceAction} from '../store/createStateSourceAction'
7
7
  import {defineStore} from '../store/defineStore'
8
+ import {getStagingApiHost} from '../utils/getStagingApiHost'
8
9
  import {resolveAuthMode} from './authMode'
9
10
  import {AuthStateType} from './authStateType'
10
11
  import {type AuthStrategyOptions} from './authStrategy'
11
12
  import {getDashboardInitialState, initializeDashboardAuth} from './dashboardAuth'
12
13
  import {getStandaloneInitialState, initializeStandaloneAuth} from './standaloneAuth'
13
14
  import {getStudioInitialState, initializeStudioAuth} from './studioAuth'
14
- import {getCleanedUrl, getDefaultLocation} from './utils'
15
+ import {createLoggedInAuthState, getCleanedUrl, getDefaultLocation} from './utils'
15
16
 
16
17
  // ---------------------------------------------------------------------------
17
18
  // Public types
@@ -102,7 +103,7 @@ export const authStore = defineStore<AuthStoreState>({
102
103
 
103
104
  getInitialState(instance) {
104
105
  const {
105
- apiHost,
106
+ apiHost: configApiHost,
106
107
  callbackUrl,
107
108
  providers: customProviders,
108
109
  token: providedToken,
@@ -110,6 +111,7 @@ export const authStore = defineStore<AuthStoreState>({
110
111
  initialLocationHref = getDefaultLocation(),
111
112
  } = instance.config.auth ?? {}
112
113
 
114
+ const apiHost = configApiHost ?? getStagingApiHost()
113
115
  const authConfig = instance.config.auth ?? {}
114
116
 
115
117
  // Build login URL (used by standalone mode, but always computed for the
@@ -274,16 +276,14 @@ export const setAuthToken = bindActionGlobally(authStore, ({state}, token: strin
274
276
  if (token) {
275
277
  // Update state only if the new token is different or currently logged out
276
278
  if (currentAuthState.type !== AuthStateType.LOGGED_IN || currentAuthState.token !== token) {
277
- // This state update structure should trigger listeners in clientStore
279
+ const currentUser =
280
+ currentAuthState.type === AuthStateType.LOGGED_IN ? currentAuthState.currentUser : null
281
+ const preservedLastTokenRefresh =
282
+ currentAuthState.type === AuthStateType.LOGGED_IN
283
+ ? currentAuthState.lastTokenRefresh
284
+ : undefined
278
285
  state.set('setToken', {
279
- authState: {
280
- type: AuthStateType.LOGGED_IN,
281
- token: token,
282
- // Keep existing user or set to null? Setting to null forces refetch.
283
- // Keep existing user to avoid unnecessary refetches if user data is still valid.
284
- currentUser:
285
- currentAuthState.type === AuthStateType.LOGGED_IN ? currentAuthState.currentUser : null,
286
- },
286
+ authState: createLoggedInAuthState(token, currentUser, preservedLastTokenRefresh),
287
287
  })
288
288
  }
289
289
  } else {
@@ -7,7 +7,7 @@ import {type AuthStoreState, type DashboardContext} from './authStore'
7
7
  import {type AuthStrategyOptions, type AuthStrategyResult} from './authStrategy'
8
8
  import {refreshStampedToken} from './refreshStampedToken'
9
9
  import {subscribeToStateAndFetchCurrentUser} from './subscribeToStateAndFetchCurrentUser'
10
- import {getAuthCode, getTokenFromLocation} from './utils'
10
+ import {createLoggedInAuthState, getAuthCode, getTokenFromLocation} from './utils'
11
11
 
12
12
  /**
13
13
  * Parses the dashboard context from a location href's `_context` URL parameter.
@@ -66,7 +66,7 @@ export function getDashboardInitialState(options: AuthStrategyOptions): AuthStra
66
66
  // Provided token always wins, even in dashboard
67
67
  if (providedToken) {
68
68
  return {
69
- authState: {type: AuthStateType.LOGGED_IN, token: providedToken, currentUser: null},
69
+ authState: createLoggedInAuthState(providedToken, null),
70
70
  storageKey,
71
71
  storageArea,
72
72
  authMethod: undefined,
@@ -2,7 +2,13 @@ import {bindActionGlobally} from '../store/createActionBinder'
2
2
  import {DEFAULT_API_VERSION, REQUEST_TAG_PREFIX} from './authConstants'
3
3
  import {AuthStateType} from './authStateType'
4
4
  import {authStore, type AuthStoreState, type DashboardContext} from './authStore'
5
- import {getAuthCode, getCleanedUrl, getDefaultLocation, getTokenFromLocation} from './utils'
5
+ import {
6
+ createLoggedInAuthState,
7
+ getAuthCode,
8
+ getCleanedUrl,
9
+ getDefaultLocation,
10
+ getTokenFromLocation,
11
+ } from './utils'
6
12
 
7
13
  /**
8
14
  * @public
@@ -27,7 +33,7 @@ export const handleAuthCallback = bindActionGlobally(
27
33
  const tokenFromUrl = getTokenFromLocation(locationHref)
28
34
  if (tokenFromUrl) {
29
35
  state.set('setTokenFromUrl', {
30
- authState: {type: AuthStateType.LOGGED_IN, token: tokenFromUrl, currentUser: null},
36
+ authState: createLoggedInAuthState(tokenFromUrl, null),
31
37
  })
32
38
  return cleanedUrl
33
39
  }
@@ -77,7 +83,7 @@ export const handleAuthCallback = bindActionGlobally(
77
83
  })
78
84
 
79
85
  storageArea?.setItem(storageKey, JSON.stringify({token}))
80
- state.set('setToken', {authState: {type: AuthStateType.LOGGED_IN, token, currentUser: null}})
86
+ state.set('setToken', {authState: createLoggedInAuthState(token, null)})
81
87
 
82
88
  return cleanedUrl
83
89
  } catch (error) {
@@ -59,7 +59,7 @@ describe('logout', () => {
59
59
  useProjectHostname: false,
60
60
  useCdn: false,
61
61
  })
62
- expect(mockRequest).toHaveBeenCalledWith({method: 'POST', uri: '/auth/logout'})
62
+ expect(mockRequest).toHaveBeenCalledWith({method: 'POST', uri: '/auth/logout', tag: 'logout'})
63
63
  expect(removeItem).toHaveBeenCalledWith('__sanity_auth_token')
64
64
  })
65
65
 
@@ -33,7 +33,7 @@ export const logout = bindActionGlobally(authStore, async ({state}) => {
33
33
  useCdn: false,
34
34
  })
35
35
 
36
- await client.request<void>({uri: '/auth/logout', method: 'POST'})
36
+ await client.request<void>({uri: '/auth/logout', method: 'POST', tag: 'logout'})
37
37
  }
38
38
  } finally {
39
39
  state.set('logoutSuccess', {
@@ -7,13 +7,13 @@ import {createSanityInstance} from '../store/createSanityInstance'
7
7
  import {createStoreState} from '../store/createStoreState'
8
8
  import {AuthStateType} from './authStateType'
9
9
  import {type AuthState, authStore} from './authStore'
10
- // Import only the public function
11
10
  import {
12
11
  getLastRefreshTime,
13
12
  getNextRefreshDelay,
14
13
  refreshStampedToken,
15
14
  setLastRefreshTime,
16
15
  } from './refreshStampedToken'
16
+ import {createLoggedInAuthState} from './utils'
17
17
 
18
18
  // Type definitions for Web Locks (can be kept if needed for context)
19
19
  // ... (Lock, LockOptions, LockGrantedCallback types)
@@ -147,6 +147,123 @@ describe('refreshStampedToken', () => {
147
147
  expect(locksRequest).not.toHaveBeenCalled()
148
148
  })
149
149
 
150
+ it('does not refresh on visibility change when lastTokenRefresh is recent', async () => {
151
+ const mockClient = {
152
+ observable: {request: vi.fn(() => of({token: 'sk-refreshed-token-st123'}))},
153
+ }
154
+ const mockClientFactory = vi.fn().mockReturnValue(mockClient)
155
+ const instance = createSanityInstance({
156
+ auth: {clientFactory: mockClientFactory, storageArea: mockStorage},
157
+ })
158
+ const initialState = authStore.getInitialState(instance, null)
159
+ initialState.authState = createLoggedInAuthState('sk-initial-token-st123', null)
160
+ initialState.dashboardContext = {mode: 'test'}
161
+ const state = createStoreState(initialState)
162
+
163
+ const subscription = refreshStampedToken({state, instance, key: null})
164
+ subscriptions.push(subscription)
165
+
166
+ const addEventListenerMock = global.document.addEventListener as ReturnType<typeof vi.fn>
167
+ expect(addEventListenerMock).toHaveBeenCalledWith('visibilitychange', expect.any(Function))
168
+ const visibilityHandler = addEventListenerMock.mock.calls[0][1] as () => void
169
+
170
+ Object.defineProperty(global.document, 'visibilityState', {
171
+ value: 'visible',
172
+ writable: true,
173
+ configurable: true,
174
+ })
175
+
176
+ visibilityHandler()
177
+ await vi.advanceTimersByTimeAsync(100)
178
+
179
+ expect(mockClient.observable.request).not.toHaveBeenCalled()
180
+ const finalAuthState = state.get().authState
181
+ if (finalAuthState.type === AuthStateType.LOGGED_IN) {
182
+ expect(finalAuthState.token).toBe('sk-initial-token-st123')
183
+ }
184
+ })
185
+
186
+ it('refreshes on visibility change when lastTokenRefresh is stale', async () => {
187
+ const REFRESH_INTERVAL = 12 * 60 * 60 * 1000
188
+ const mockClient = {
189
+ observable: {request: vi.fn(() => of({token: 'sk-refreshed-token-st123'}))},
190
+ }
191
+ const mockClientFactory = vi.fn().mockReturnValue(mockClient)
192
+ const instance = createSanityInstance({
193
+ auth: {clientFactory: mockClientFactory, storageArea: mockStorage},
194
+ })
195
+ const initialState = authStore.getInitialState(instance, null)
196
+ const staleTimestamp = Date.now() - REFRESH_INTERVAL - 1000
197
+ initialState.authState = {
198
+ type: AuthStateType.LOGGED_IN,
199
+ token: 'sk-initial-token-st123',
200
+ currentUser: null,
201
+ lastTokenRefresh: staleTimestamp,
202
+ }
203
+ initialState.dashboardContext = {mode: 'test'}
204
+ const state = createStoreState(initialState)
205
+
206
+ const subscription = refreshStampedToken({state, instance, key: null})
207
+ subscriptions.push(subscription)
208
+
209
+ const addEventListenerMock = global.document.addEventListener as ReturnType<typeof vi.fn>
210
+ expect(addEventListenerMock).toHaveBeenCalledWith('visibilitychange', expect.any(Function))
211
+ const visibilityHandler = addEventListenerMock.mock.calls[0][1] as () => void
212
+
213
+ Object.defineProperty(global.document, 'visibilityState', {
214
+ value: 'visible',
215
+ writable: true,
216
+ configurable: true,
217
+ })
218
+
219
+ visibilityHandler()
220
+ await vi.advanceTimersToNextTimerAsync()
221
+
222
+ expect(mockClient.observable.request).toHaveBeenCalled()
223
+ const finalAuthState = state.get().authState
224
+ if (finalAuthState.type === AuthStateType.LOGGED_IN) {
225
+ expect(finalAuthState.token).toBe('sk-refreshed-token-st123')
226
+ expect(finalAuthState.lastTokenRefresh).toBeGreaterThan(staleTimestamp)
227
+ }
228
+ })
229
+
230
+ it('refreshes on visibility change when lastTokenRefresh is undefined (pre-fix behavior)', async () => {
231
+ const mockClient = {
232
+ observable: {request: vi.fn(() => of({token: 'sk-refreshed-token-st123'}))},
233
+ }
234
+ const mockClientFactory = vi.fn().mockReturnValue(mockClient)
235
+ const instance = createSanityInstance({
236
+ auth: {clientFactory: mockClientFactory, storageArea: mockStorage},
237
+ })
238
+ const initialState = authStore.getInitialState(instance, null)
239
+ initialState.authState = {
240
+ type: AuthStateType.LOGGED_IN,
241
+ token: 'sk-initial-token-st123',
242
+ currentUser: null,
243
+ // lastTokenRefresh intentionally omitted to demonstrate the old bug
244
+ }
245
+ initialState.dashboardContext = {mode: 'test'}
246
+ const state = createStoreState(initialState)
247
+
248
+ const subscription = refreshStampedToken({state, instance, key: null})
249
+ subscriptions.push(subscription)
250
+
251
+ const addEventListenerMock = global.document.addEventListener as ReturnType<typeof vi.fn>
252
+ const visibilityHandler = addEventListenerMock.mock.calls[0][1] as () => void
253
+
254
+ Object.defineProperty(global.document, 'visibilityState', {
255
+ value: 'visible',
256
+ writable: true,
257
+ configurable: true,
258
+ })
259
+
260
+ visibilityHandler()
261
+ await vi.advanceTimersToNextTimerAsync()
262
+
263
+ // Without lastTokenRefresh, shouldRefreshToken returns true — this is the bug
264
+ expect(mockClient.observable.request).toHaveBeenCalled()
265
+ })
266
+
150
267
  it('does not refresh when tab is not visible', async () => {
151
268
  // Set visibility to hidden
152
269
  Object.defineProperty(global, 'document', {
@@ -13,7 +13,7 @@ import {
13
13
  } from 'rxjs'
14
14
 
15
15
  import {type StoreContext} from '../store/defineStore'
16
- import {DEFAULT_API_VERSION} from './authConstants'
16
+ import {DEFAULT_API_VERSION, REQUEST_TAG_PREFIX} from './authConstants'
17
17
  import {AuthStateType} from './authStateType'
18
18
  import {type AuthState, type AuthStoreState} from './authStore'
19
19
 
@@ -58,7 +58,7 @@ function createTokenRefreshStream(
58
58
  return new Observable((subscriber) => {
59
59
  const client = clientFactory({
60
60
  apiVersion: DEFAULT_API_VERSION,
61
- requestTagPrefix: 'token-refresh',
61
+ requestTagPrefix: REQUEST_TAG_PREFIX,
62
62
  useProjectHostname: false,
63
63
  useCdn: false,
64
64
  token,
@@ -70,6 +70,7 @@ function createTokenRefreshStream(
70
70
  .request<{token: string}>({
71
71
  uri: 'auth/refresh-token',
72
72
  method: 'POST',
73
+ tag: 'refresh-token',
73
74
  body: {
74
75
  token,
75
76
  },
@@ -7,7 +7,13 @@ import {type AuthStrategyOptions, type AuthStrategyResult} from './authStrategy'
7
7
  import {refreshStampedToken} from './refreshStampedToken'
8
8
  import {subscribeToStateAndFetchCurrentUser} from './subscribeToStateAndFetchCurrentUser'
9
9
  import {subscribeToStorageEventsAndSetToken} from './subscribeToStorageEventsAndSetToken'
10
- import {getAuthCode, getDefaultStorage, getTokenFromLocation, getTokenFromStorage} from './utils'
10
+ import {
11
+ createLoggedInAuthState,
12
+ getAuthCode,
13
+ getDefaultStorage,
14
+ getTokenFromLocation,
15
+ getTokenFromStorage,
16
+ } from './utils'
11
17
 
12
18
  /**
13
19
  * Resolves the initial auth state for Standalone mode.
@@ -30,7 +36,7 @@ export function getStandaloneInitialState(options: AuthStrategyOptions): AuthStr
30
36
  // Provided token always wins
31
37
  if (providedToken) {
32
38
  return {
33
- authState: {type: AuthStateType.LOGGED_IN, token: providedToken, currentUser: null},
39
+ authState: createLoggedInAuthState(providedToken, null),
34
40
  storageKey,
35
41
  storageArea,
36
42
  authMethod: undefined,
@@ -53,7 +59,7 @@ export function getStandaloneInitialState(options: AuthStrategyOptions): AuthStr
53
59
  const token = getTokenFromStorage(storageArea, storageKey)
54
60
  if (token) {
55
61
  return {
56
- authState: {type: AuthStateType.LOGGED_IN, token, currentUser: null},
62
+ authState: createLoggedInAuthState(token, null),
57
63
  storageKey,
58
64
  storageArea,
59
65
  authMethod: 'localstorage',