@sanity/sdk 2.8.0 → 2.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/dist/_chunks-dts/utils.d.ts +2396 -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 +1460 -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 +383 -1777
  15. package/dist/index.js.map +1 -1
  16. package/package.json +11 -4
  17. package/src/_exports/_internal.ts +14 -0
  18. package/src/_exports/index.ts +10 -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 +14 -0
  35. package/src/client/clientStore.ts +2 -1
  36. package/src/comlink/node/getNodeState.ts +2 -1
  37. package/src/config/sanityConfig.ts +6 -0
  38. package/src/document/actions.ts +18 -11
  39. package/src/document/applyDocumentActions.test.ts +7 -6
  40. package/src/document/applyDocumentActions.ts +10 -4
  41. package/src/document/documentStore.test.ts +536 -188
  42. package/src/document/documentStore.ts +142 -76
  43. package/src/document/events.ts +7 -2
  44. package/src/document/permissions.test.ts +18 -16
  45. package/src/document/permissions.ts +35 -11
  46. package/src/document/processActions.test.ts +359 -32
  47. package/src/document/processActions.ts +104 -76
  48. package/src/document/reducers.test.ts +117 -29
  49. package/src/document/reducers.ts +43 -36
  50. package/src/document/sharedListener.ts +16 -6
  51. package/src/document/util.ts +14 -0
  52. package/src/favorites/favorites.test.ts +9 -2
  53. package/src/presence/bifurTransport.ts +6 -1
  54. package/src/preview/getPreviewState.test.ts +115 -98
  55. package/src/preview/getPreviewState.ts +38 -60
  56. package/src/preview/previewProjectionUtils.test.ts +179 -0
  57. package/src/preview/previewProjectionUtils.ts +93 -0
  58. package/src/preview/resolvePreview.test.ts +42 -25
  59. package/src/preview/resolvePreview.ts +29 -10
  60. package/src/preview/{previewStore.ts → types.ts} +8 -17
  61. package/src/projection/getProjectionState.test.ts +16 -16
  62. package/src/projection/getProjectionState.ts +2 -1
  63. package/src/projection/projectionQuery.ts +2 -3
  64. package/src/projection/types.ts +1 -1
  65. package/src/query/queryStore.ts +2 -1
  66. package/src/releases/getPerspectiveState.ts +7 -6
  67. package/src/releases/releasesStore.test.ts +20 -5
  68. package/src/releases/releasesStore.ts +20 -8
  69. package/src/store/createStateSourceAction.test.ts +62 -0
  70. package/src/store/createStateSourceAction.ts +34 -39
  71. package/src/telemetry/__telemetry__/sdk.telemetry.ts +42 -0
  72. package/src/telemetry/devMode.test.ts +52 -0
  73. package/src/telemetry/devMode.ts +40 -0
  74. package/src/telemetry/initTelemetry.test.ts +225 -0
  75. package/src/telemetry/initTelemetry.ts +205 -0
  76. package/src/telemetry/telemetryManager.test.ts +263 -0
  77. package/src/telemetry/telemetryManager.ts +187 -0
  78. package/src/users/usersStore.test.ts +1 -0
  79. package/src/users/usersStore.ts +5 -1
  80. package/src/utils/createFetcherStore.test.ts +6 -4
  81. package/src/utils/createFetcherStore.ts +2 -1
  82. package/src/utils/getStagingApiHost.test.ts +21 -0
  83. package/src/utils/getStagingApiHost.ts +14 -0
  84. package/src/utils/ids.test.ts +1 -29
  85. package/src/utils/ids.ts +0 -10
  86. package/src/utils/setCleanupTimeout.ts +24 -0
  87. package/src/preview/previewQuery.test.ts +0 -236
  88. package/src/preview/previewQuery.ts +0 -153
  89. package/src/preview/previewStore.test.ts +0 -36
  90. package/src/preview/subscribeToStateAndFetchBatches.test.ts +0 -221
  91. package/src/preview/subscribeToStateAndFetchBatches.ts +0 -112
  92. 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.9.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",
@@ -49,9 +54,11 @@
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
59
  "@sanity/message-protocol": "^0.18.0",
54
- "@sanity/mutate": "^0.12.4",
60
+ "@sanity/mutate": "^0.16.1",
61
+ "@sanity/telemetry": "^1.0.0",
55
62
  "@sanity/types": "^5.2.0",
56
63
  "groq": "3.88.1-typegen-experimental.0",
57
64
  "groq-js": "^1.19.0",
@@ -70,7 +77,7 @@
70
77
  "prettier": "^3.7.3",
71
78
  "rollup-plugin-visualizer": "^5.14.0",
72
79
  "typescript": "^5.8.3",
73
- "vite": "^6.3.4",
80
+ "vite": "^7.0.0",
74
81
  "vitest": "^3.2.4",
75
82
  "@repo/config-eslint": "0.0.0",
76
83
  "@repo/config-test": "0.0.1",
@@ -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'
@@ -141,6 +141,7 @@ export {
141
141
  type DocumentEditedEvent,
142
142
  type DocumentEvent,
143
143
  type DocumentPublishedEvent,
144
+ type DocumentTransactionSubmissionResult,
144
145
  type DocumentUnpublishedEvent,
145
146
  type TransactionAcceptedEvent,
146
147
  type TransactionRevertedEvent,
@@ -159,8 +160,16 @@ export type {
159
160
  UserPresence,
160
161
  } from '../presence/types'
161
162
  export {getPreviewState, type GetPreviewStateOptions} from '../preview/getPreviewState'
162
- export type {PreviewStoreState, PreviewValue, ValuePending} from '../preview/previewStore'
163
+ export {PREVIEW_PROJECTION} from '../preview/previewConstants'
164
+ export {transformProjectionToPreview} from '../preview/previewProjectionUtils'
163
165
  export {resolvePreview, type ResolvePreviewOptions} from '../preview/resolvePreview'
166
+ export type {
167
+ PreviewMedia,
168
+ PreviewQueryResult,
169
+ PreviewStoreState,
170
+ PreviewValue,
171
+ ValuePending,
172
+ } from '../preview/types'
164
173
  export {type OrgVerificationResult} from '../project/organizationVerification'
165
174
  export {getProjectState, resolveProject} from '../project/project'
166
175
  export {getProjectionState} from '../projection/getProjectionState'
@@ -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',