@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
@@ -0,0 +1,187 @@
1
+ import {type SanityClient} from '@sanity/client'
2
+ import {
3
+ type ConsentStatus,
4
+ createBatchedStore,
5
+ type SessionId,
6
+ type TelemetryEvent,
7
+ type TelemetryLogger,
8
+ type TelemetryStore,
9
+ } from '@sanity/telemetry'
10
+
11
+ import {createLogger} from '../utils/logger'
12
+ import {CORE_SDK_VERSION} from '../version'
13
+ import {
14
+ SDKDevError,
15
+ SDKDevSessionEnded,
16
+ SDKDevSessionStarted,
17
+ SDKHookMounted,
18
+ } from './__telemetry__/sdk.telemetry'
19
+
20
+ const FLUSH_INTERVAL_MS = 30_000
21
+ const CONSENT_TAG = 'telemetry-consent.sdk'
22
+ const BATCH_TAG = 'telemetry.batch'
23
+
24
+ const log = createLogger('telemetry')
25
+
26
+ /**
27
+ * Manages dev-mode telemetry for a single SDK instance.
28
+ *
29
+ * Wraps `@sanity/telemetry`'s batched store with SDK-specific concerns:
30
+ * consent caching, session lifecycle events, and hook usage tracking.
31
+ *
32
+ * @internal
33
+ */
34
+ export interface TelemetryManager {
35
+ /**
36
+ * Eagerly resolve and cache the user's consent status.
37
+ * Returns true only when the user has explicitly opted in (`granted`).
38
+ * Call this before logging any events to avoid buffering events that
39
+ * will be dropped on the first flush.
40
+ */
41
+ checkConsent(): Promise<boolean>
42
+
43
+ /** Log a "SDK Dev Session Started" event */
44
+ logSessionStarted(data: {projectId: string; perspective: string; authMethod: string}): void
45
+
46
+ /** Log a "SDK Hook First Used" event (deduplicated per hook name) */
47
+ logHookFirstUsed(hookName: string): void
48
+
49
+ /** Log a "SDK Dev Error" event */
50
+ logDevError(errorType: string, hookName: string): void
51
+
52
+ /** Log a "SDK Dev Session Ended" event and tear down the store */
53
+ endSession(): void
54
+
55
+ /** Tear down the store without logging a session-end event */
56
+ dispose(): void
57
+
58
+ /** The set of hook names used during this session */
59
+ readonly hooksUsed: ReadonlySet<string>
60
+ }
61
+
62
+ interface TelemetryManagerOptions {
63
+ sessionId: string
64
+ getClient: () => SanityClient
65
+ projectId: string
66
+ }
67
+
68
+ /**
69
+ * Creates a telemetry manager for a single SDK instance session.
70
+ *
71
+ * The manager initializes a `createBatchedStore` from `@sanity/telemetry`,
72
+ * caches the consent check for the lifetime of the session, and provides
73
+ * typed methods for each SDK telemetry event.
74
+ *
75
+ * @internal
76
+ */
77
+ export function createTelemetryManager(options: TelemetryManagerOptions): TelemetryManager {
78
+ const {sessionId, getClient, projectId} = options
79
+ const startedAt = Date.now()
80
+ const emittedHooks = new Set<string>()
81
+
82
+ let cachedConsent: {status: ConsentStatus} | null = null
83
+
84
+ const resolveConsent = async (): Promise<{status: ConsentStatus}> => {
85
+ if (cachedConsent) return cachedConsent
86
+ try {
87
+ const client = getClient()
88
+ const result = await client.request<{status: ConsentStatus}>({
89
+ uri: '/intake/telemetry-status',
90
+ tag: CONSENT_TAG,
91
+ })
92
+ cachedConsent = result
93
+ } catch {
94
+ cachedConsent = {status: 'undetermined'}
95
+ }
96
+ return cachedConsent
97
+ }
98
+
99
+ const enrichBatch = (batch: TelemetryEvent[]) =>
100
+ batch.map((event) => ({
101
+ ...event,
102
+ context: {
103
+ version: CORE_SDK_VERSION,
104
+ environment: 'development' as const,
105
+ origin: typeof window !== 'undefined' ? window.location.origin : 'node',
106
+ },
107
+ }))
108
+
109
+ const sendEvents = async (batch: TelemetryEvent[]): Promise<unknown> => {
110
+ const client = getClient()
111
+ log.debug('sending event batch', {batchSize: batch.length})
112
+ return client.request({
113
+ uri: '/intake/batch',
114
+ method: 'POST',
115
+ body: {projectId, batch: enrichBatch(batch)},
116
+ tag: BATCH_TAG,
117
+ })
118
+ }
119
+
120
+ const store: TelemetryStore<Record<string, unknown>> = createBatchedStore(
121
+ sessionId as SessionId,
122
+ {
123
+ flushInterval: FLUSH_INTERVAL_MS,
124
+ resolveConsent,
125
+ sendEvents,
126
+ },
127
+ )
128
+
129
+ const logger: TelemetryLogger<Record<string, unknown>> = store.logger
130
+
131
+ return {
132
+ async checkConsent() {
133
+ const {status} = await resolveConsent()
134
+ return status === 'granted'
135
+ },
136
+
137
+ logSessionStarted(data) {
138
+ log.debug('event: SDK Dev Session Started', {
139
+ projectId: data.projectId,
140
+ perspective: data.perspective,
141
+ authMethod: data.authMethod,
142
+ version: CORE_SDK_VERSION,
143
+ })
144
+ logger.log(SDKDevSessionStarted, {
145
+ version: CORE_SDK_VERSION,
146
+ ...data,
147
+ })
148
+ },
149
+
150
+ logHookFirstUsed(hookName: string) {
151
+ if (emittedHooks.has(hookName)) return
152
+ emittedHooks.add(hookName)
153
+ log.debug('event: SDK Hook Mounted', {hookName})
154
+ logger.log(SDKHookMounted, {hookName})
155
+ },
156
+
157
+ logDevError(errorType: string, hookName: string) {
158
+ log.debug('event: SDK Dev Error', {errorType, hookName})
159
+ logger.log(SDKDevError, {errorType, hookName})
160
+ },
161
+
162
+ endSession() {
163
+ const durationSeconds = Math.round((Date.now() - startedAt) / 1000)
164
+ log.debug('event: SDK Dev Session Ended', {
165
+ durationSeconds,
166
+ hooksUsed: [...emittedHooks],
167
+ })
168
+ logger.log(SDKDevSessionEnded, {
169
+ durationSeconds,
170
+ hooksUsed: [...emittedHooks],
171
+ })
172
+
173
+ store.flush().catch(() => {
174
+ // Best-effort flush on dispose; swallow errors
175
+ })
176
+ store.end()
177
+ },
178
+
179
+ dispose() {
180
+ store.end()
181
+ },
182
+
183
+ get hooksUsed(): ReadonlySet<string> {
184
+ return emittedHooks
185
+ },
186
+ }
187
+ }
@@ -1,6 +1,5 @@
1
- import {omit} from 'lodash-es'
2
-
3
1
  import {type SanityInstance} from '../store/createSanityInstance'
2
+ import {omitProperty} from '../utils/object'
4
3
  import {type GetUsersOptions, type SanityUserResponse, type UsersStoreState} from './types'
5
4
  import {DEFAULT_USERS_BATCH_SIZE} from './usersConstants'
6
5
 
@@ -48,7 +47,7 @@ export const removeSubscription =
48
47
  const group = prev.users[key]
49
48
  if (!group) return prev
50
49
  const subscriptions = group.subscriptions.filter((id) => id !== subscriptionId)
51
- if (!subscriptions.length) return {...prev, users: omit(prev.users, key)}
50
+ if (!subscriptions.length) return {...prev, users: omitProperty(prev.users, key)}
52
51
  return {...prev, users: {...prev.users, [key]: {...group, subscriptions}}}
53
52
  }
54
53
 
@@ -83,7 +82,7 @@ export const cancelRequest =
83
82
  const group = prev.users[key]
84
83
  if (!group) return prev
85
84
  if (group.subscriptions.length) return prev
86
- return {...prev, users: omit(prev.users, key)}
85
+ return {...prev, users: omitProperty(prev.users, key)}
87
86
  }
88
87
 
89
88
  export const initializeRequest =
@@ -451,6 +451,7 @@ describe('usersStore', () => {
451
451
  expect(specificRequest).toHaveBeenCalledWith({
452
452
  method: 'GET',
453
453
  uri: `/users/${projectUserId}`,
454
+ tag: 'users.get',
454
455
  })
455
456
 
456
457
  const expectedUser: SanityUser = {
@@ -31,6 +31,7 @@ import {createStateSourceAction, type SelectorContext} from '../store/createStat
31
31
  import {type StoreState} from '../store/createStoreState'
32
32
  import {defineStore, type StoreContext} from '../store/defineStore'
33
33
  import {insecureRandomId} from '../utils/ids'
34
+ import {setCleanupTimeout} from '../utils/setCleanupTimeout'
34
35
  import {
35
36
  addSubscription,
36
37
  cancelRequest,
@@ -130,6 +131,7 @@ const listenForLoadMoreAndFetch = ({state, instance}: StoreContext<UsersStoreSta
130
131
  .request<PatchedSanityUserFromClient>({
131
132
  method: 'GET',
132
133
  uri: `/users/${userId}`,
134
+ tag: 'users.get',
133
135
  })
134
136
  .pipe(
135
137
  map((user) => {
@@ -183,6 +185,7 @@ const listenForLoadMoreAndFetch = ({state, instance}: StoreContext<UsersStoreSta
183
185
  .request<SanityUser | SanityUserResponse>({
184
186
  method: 'GET',
185
187
  uri: `access/${resourceType}/${resourceId}/users/${userId}`,
188
+ tag: 'users.get',
186
189
  })
187
190
  .pipe(
188
191
  map((response) => {
@@ -251,6 +254,7 @@ const listenForLoadMoreAndFetch = ({state, instance}: StoreContext<UsersStoreSta
251
254
  client.observable.request<SanityUserResponse>({
252
255
  method: 'GET',
253
256
  uri: `access/${resource.type}/${resource.id}/users`,
257
+ tag: 'users.list',
254
258
  query: cursor
255
259
  ? {nextCursor: cursor, limit: batchSize.toString()}
256
260
  : {limit: batchSize.toString()},
@@ -310,7 +314,7 @@ export const getUsersState = bindActionGlobally(
310
314
  const key = getUsersKey(instance, options)
311
315
  state.set('addSubscription', addSubscription(subscriptionId, key))
312
316
  return () => {
313
- setTimeout(
317
+ setCleanupTimeout(
314
318
  () => state.set('removeSubscription', removeSubscription(subscriptionId, key)),
315
319
  USERS_STATE_CLEAR_DELAY,
316
320
  )
@@ -127,8 +127,9 @@ describe('createFetcherStore', () => {
127
127
  expect(fetchSpy).toHaveBeenCalledTimes(1)
128
128
 
129
129
  // Second subscription within throttle interval
130
- const sub2 = stateSource.subscribe()
131
- await firstValueFrom(stateSource.observable)
130
+ const stateSource2 = store.getState(instance, 1)
131
+ const sub2 = stateSource2.subscribe()
132
+ await firstValueFrom(stateSource2.observable)
132
133
  expect(fetchSpy).toHaveBeenCalledTimes(1)
133
134
 
134
135
  // Advance past throttle interval
@@ -136,8 +137,9 @@ describe('createFetcherStore', () => {
136
137
  await vi.advanceTimersByTimeAsync(1000)
137
138
 
138
139
  // Third subscription after throttle interval
139
- const sub3 = stateSource.subscribe()
140
- await firstValueFrom(stateSource.observable)
140
+ const stateSource3 = store.getState(instance, 1)
141
+ const sub3 = stateSource3.subscribe()
142
+ await firstValueFrom(stateSource3.observable)
141
143
  expect(fetchSpy).toHaveBeenCalledTimes(2)
142
144
 
143
145
  sub1()
@@ -1,4 +1,3 @@
1
- import {omit} from 'lodash-es'
2
1
  import {asapScheduler, EMPTY, firstValueFrom, from, Observable} from 'rxjs'
3
2
  import {
4
3
  catchError,
@@ -23,6 +22,8 @@ import {
23
22
  } from '../store/createStateSourceAction'
24
23
  import {defineStore, type StoreContext} from '../store/defineStore'
25
24
  import {insecureRandomId} from '../utils/ids'
25
+ import {omitProperty} from '../utils/object'
26
+ import {setCleanupTimeout} from './setCleanupTimeout'
26
27
 
27
28
  interface CreateFetcherStoreOptions<TParams extends unknown[], TData> {
28
29
  /**
@@ -182,8 +183,10 @@ export function createFetcherStore<TParams extends unknown[], TData>({
182
183
  stateByParams: {
183
184
  ...prev.stateByParams,
184
185
  [entry.key]: {
185
- ...omit(entry, 'error'),
186
- ...omit(prev.stateByParams[entry.key], 'error'),
186
+ ...omitProperty(entry, 'error'),
187
+ ...(prev.stateByParams[entry.key]
188
+ ? omitProperty(prev.stateByParams[entry.key], 'error')
189
+ : {}),
187
190
  data,
188
191
  },
189
192
  },
@@ -247,14 +250,14 @@ export function createFetcherStore<TParams extends unknown[], TData>({
247
250
  }))
248
251
 
249
252
  return () => {
250
- setTimeout(() => {
253
+ setCleanupTimeout(() => {
251
254
  state.set('removeSubscription', (prev: FetcherStoreState<TParams, TData>) => {
252
255
  const entry = prev.stateByParams[key]
253
256
  if (!entry) return prev
254
257
 
255
258
  const newSubs = (entry.subscriptions || []).filter((id) => id !== subscriptionId)
256
259
  if (newSubs.length === 0) {
257
- return {stateByParams: omit(prev.stateByParams, key)}
260
+ return {stateByParams: omitProperty(prev.stateByParams, key)}
258
261
  }
259
262
 
260
263
  return {
@@ -0,0 +1,21 @@
1
+ import {describe, expect, it, vi} from 'vitest'
2
+
3
+ import {getStagingApiHost} from './getStagingApiHost'
4
+
5
+ describe('getStagingApiHost', () => {
6
+ it('returns staging host when __SANITY_STAGING__ is true', () => {
7
+ vi.stubGlobal('__SANITY_STAGING__', true)
8
+ expect(getStagingApiHost()).toBe('https://api.sanity.work')
9
+ vi.unstubAllGlobals()
10
+ })
11
+
12
+ it('returns undefined when __SANITY_STAGING__ is false', () => {
13
+ vi.stubGlobal('__SANITY_STAGING__', false)
14
+ expect(getStagingApiHost()).toBeUndefined()
15
+ vi.unstubAllGlobals()
16
+ })
17
+
18
+ it('returns undefined when __SANITY_STAGING__ is not defined', () => {
19
+ expect(getStagingApiHost()).toBeUndefined()
20
+ })
21
+ })
@@ -0,0 +1,14 @@
1
+ declare const __SANITY_STAGING__: boolean | undefined
2
+
3
+ /**
4
+ * Returns the staging API host if the `__SANITY_STAGING__` build-time flag is
5
+ * set to `true` (mirroring how Sanity Studio detects staging builds).
6
+ *
7
+ * @internal
8
+ */
9
+ export function getStagingApiHost(): string | undefined {
10
+ if (typeof __SANITY_STAGING__ !== 'undefined' && __SANITY_STAGING__ === true) {
11
+ return 'https://api.sanity.work'
12
+ }
13
+ return undefined
14
+ }
@@ -1,34 +1,6 @@
1
1
  import {describe, expect, it} from 'vitest'
2
2
 
3
- import {getDraftId, getPublishedId, insecureRandomId} from './ids'
4
-
5
- describe('getDraftId', () => {
6
- it('should add drafts prefix to non-draft ids', () => {
7
- expect(getDraftId('abc123')).toBe('drafts.abc123')
8
- })
9
-
10
- it('should not modify ids that already have drafts prefix', () => {
11
- expect(getDraftId('drafts.abc123')).toBe('drafts.abc123')
12
- })
13
-
14
- it('should handle empty string', () => {
15
- expect(getDraftId('')).toBe('drafts.')
16
- })
17
- })
18
-
19
- describe('getPublishedId', () => {
20
- it('should remove drafts prefix from draft ids', () => {
21
- expect(getPublishedId('drafts.abc123')).toBe('abc123')
22
- })
23
-
24
- it('should not modify ids that dont have drafts prefix', () => {
25
- expect(getPublishedId('abc123')).toBe('abc123')
26
- })
27
-
28
- it('should handle empty string', () => {
29
- expect(getPublishedId('')).toBe('')
30
- })
31
- })
3
+ import {insecureRandomId} from './ids'
32
4
 
33
5
  describe('insecureRandomId', () => {
34
6
  it('should generate 16-character string', () => {
package/src/utils/ids.ts CHANGED
@@ -1,13 +1,3 @@
1
- export function getPublishedId(id: string): string {
2
- const draftsPrefix = 'drafts.'
3
- return id.startsWith(draftsPrefix) ? id.slice(draftsPrefix.length) : id
4
- }
5
-
6
- export function getDraftId(id: string): string {
7
- const draftsPrefix = 'drafts.'
8
- return id.startsWith(draftsPrefix) ? id : `${draftsPrefix}${id}`
9
- }
10
-
11
1
  export function insecureRandomId(): string {
12
2
  return Array.from({length: 16}, () => Math.floor(Math.random() * 16).toString(16)).join('')
13
3
  }
@@ -0,0 +1,72 @@
1
+ import {describe, expect, test} from 'vitest'
2
+
3
+ import {isImportError} from './isImportError'
4
+
5
+ describe('isImportError', () => {
6
+ test('returns false for non-Error values', () => {
7
+ expect(isImportError(null)).toBe(false)
8
+ expect(isImportError(undefined)).toBe(false)
9
+ expect(isImportError('Loading chunk 5 failed.')).toBe(false)
10
+ expect(isImportError({message: 'Loading chunk 5 failed.'})).toBe(false)
11
+ expect(isImportError(42)).toBe(false)
12
+ })
13
+
14
+ test('returns false for unrelated Error instances', () => {
15
+ expect(isImportError(new Error('Something else went wrong'))).toBe(false)
16
+ expect(isImportError(new TypeError('Cannot read properties of undefined'))).toBe(false)
17
+ })
18
+
19
+ test('detects webpack ChunkLoadError by name', () => {
20
+ const err = new Error('arbitrary message')
21
+ err.name = 'ChunkLoadError'
22
+ expect(isImportError(err)).toBe(true)
23
+ })
24
+
25
+ test('detects webpack numeric chunk failure messages', () => {
26
+ expect(isImportError(new Error('Loading chunk 5 failed.'))).toBe(true)
27
+ expect(
28
+ isImportError(new Error('Loading chunk 42 failed. (missing: https://x.com/42.abc.js)')),
29
+ ).toBe(true)
30
+ })
31
+
32
+ test('detects webpack named chunk failure messages', () => {
33
+ expect(isImportError(new Error('Loading chunk vendors-foo failed.'))).toBe(true)
34
+ expect(isImportError(new Error('Loading chunk react_vendors failed.'))).toBe(true)
35
+ })
36
+
37
+ test('detects Vite "Failed to fetch dynamically imported module"', () => {
38
+ expect(
39
+ isImportError(
40
+ new TypeError(
41
+ 'Failed to fetch dynamically imported module: https://example.com/assets/Home-abc123.js',
42
+ ),
43
+ ),
44
+ ).toBe(true)
45
+ })
46
+
47
+ test('detects Firefox "error loading dynamically imported module"', () => {
48
+ expect(
49
+ isImportError(
50
+ new TypeError(
51
+ 'error loading dynamically imported module: http://localhost:8080/src/views/Dashboard/index.vue',
52
+ ),
53
+ ),
54
+ ).toBe(true)
55
+ })
56
+
57
+ test('detects Safari module-script failures with and without the "ing" suffix', () => {
58
+ expect(isImportError(new TypeError('Importing a module script failed.'))).toBe(true)
59
+ expect(isImportError(new TypeError('Import a module script failed.'))).toBe(true)
60
+ })
61
+
62
+ test('detects Vite CSS preload failures', () => {
63
+ expect(isImportError(new Error('Unable to preload CSS for /assets/App-BBLnt7oG.css'))).toBe(
64
+ true,
65
+ )
66
+ })
67
+
68
+ test('matches case-insensitively', () => {
69
+ expect(isImportError(new Error('loading chunk 1 FAILED'))).toBe(true)
70
+ expect(isImportError(new Error('FAILED TO FETCH DYNAMICALLY IMPORTED MODULE'))).toBe(true)
71
+ })
72
+ })
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Returns true when the given error looks like a dynamic-import or
3
+ * code-split chunk-loading failure.
4
+ *
5
+ * These errors typically surface when a user has a tab open against a
6
+ * previously-deployed version of an app and the JavaScript or CSS chunk
7
+ * filenames have since changed: a fresh deployment removes the hashed assets
8
+ * the open tab still references. Detecting them lets the SDK trigger an
9
+ * automatic reload so the user gets the new build without manual intervention.
10
+ *
11
+ * Recognized shapes (webpack ChunkLoadError, Vite "Failed to fetch
12
+ * dynamically imported module", Firefox "error loading dynamically imported
13
+ * module", Safari "Importing a module script failed", and Vite "Unable to
14
+ * preload CSS").
15
+ *
16
+ * @param error - The value to inspect. Anything that is not an Error
17
+ * instance returns false.
18
+ * @returns True if the error matches a known import/chunk-load failure.
19
+ *
20
+ * @public
21
+ */
22
+ export function isImportError(error: unknown): boolean {
23
+ if (!(error instanceof Error)) return false
24
+ if (error.name === 'ChunkLoadError') return true
25
+
26
+ const message = error.message || ''
27
+ return (
28
+ /Loading chunk [\w-]+ failed/i.test(message) ||
29
+ /Failed to fetch dynamically imported module/i.test(message) ||
30
+ /error loading dynamically imported module/i.test(message) ||
31
+ /Import(?:ing)? a module script failed/i.test(message) ||
32
+ /Unable to preload CSS/i.test(message)
33
+ )
34
+ }
@@ -0,0 +1,95 @@
1
+ import {describe, expect, it} from 'vitest'
2
+
3
+ import {isDeepEqual, isObject, omitProperty, pickProperties} from './object'
4
+
5
+ describe('object utils', () => {
6
+ describe('isObject', () => {
7
+ it('returns true for objects and false for primitives', () => {
8
+ expect(isObject({foo: 'bar'})).toBe(true)
9
+ expect(isObject(null)).toBe(false)
10
+ expect(isObject('hello')).toBe(false)
11
+ })
12
+ })
13
+
14
+ describe('omitProperty', () => {
15
+ it('removes a property from an object copy', () => {
16
+ expect(omitProperty({foo: 'bar', baz: 1}, 'foo')).toEqual({baz: 1})
17
+ })
18
+
19
+ it('returns an empty object for undefined input', () => {
20
+ expect(omitProperty<{foo: string}, 'foo'>(undefined, 'foo')).toEqual({})
21
+ })
22
+ })
23
+
24
+ describe('pickProperties', () => {
25
+ it('copies only the requested own properties', () => {
26
+ expect(pickProperties({foo: 'bar', baz: 1}, ['foo'])).toEqual({foo: 'bar'})
27
+ })
28
+ })
29
+
30
+ describe('isDeepEqual', () => {
31
+ it('matches nested plain objects and arrays', () => {
32
+ expect(
33
+ isDeepEqual({foo: [{bar: 'baz'}], qux: {count: 2}}, {foo: [{bar: 'baz'}], qux: {count: 2}}),
34
+ ).toBe(true)
35
+ })
36
+
37
+ it('returns false for unequal nested plain objects and arrays', () => {
38
+ expect(
39
+ isDeepEqual(
40
+ {foo: [{bar: 'baz'}], qux: {count: 2}},
41
+ {foo: [{bar: 'nope'}], qux: {count: 2}},
42
+ ),
43
+ ).toBe(false)
44
+ expect(isDeepEqual([1, {foo: 'bar'}], [1, {foo: 'baz'}])).toBe(false)
45
+ })
46
+
47
+ it('matches sets and maps with equal contents', () => {
48
+ expect(isDeepEqual(new Set([{foo: 'bar'}, 'baz']), new Set(['baz', {foo: 'bar'}]))).toBe(true)
49
+
50
+ expect(
51
+ isDeepEqual(
52
+ new Map<string, unknown>([
53
+ ['foo', {bar: 'baz'}],
54
+ ['count', 2],
55
+ ]),
56
+ new Map<string, unknown>([
57
+ ['foo', {bar: 'baz'}],
58
+ ['count', 2],
59
+ ]),
60
+ ),
61
+ ).toBe(true)
62
+ })
63
+
64
+ it('compares dates by timestamp', () => {
65
+ expect(isDeepEqual(new Date('2024-01-01'), new Date('2024-01-01'))).toBe(true)
66
+ expect(isDeepEqual(new Date('2024-01-01'), new Date('2024-01-02'))).toBe(false)
67
+ })
68
+
69
+ it('compares regular expressions by source and flags', () => {
70
+ expect(isDeepEqual(/foo/gi, /foo/gi)).toBe(true)
71
+ expect(isDeepEqual(/foo/g, /foo/i)).toBe(false)
72
+ expect(isDeepEqual(/foo/g, /bar/g)).toBe(false)
73
+ })
74
+
75
+ it('returns false for cross-shape mismatches', () => {
76
+ expect(isDeepEqual({foo: 'bar'}, ['foo', 'bar'] as unknown as {foo: string})).toBe(false)
77
+ expect(
78
+ isDeepEqual(
79
+ new Map<string, unknown>([['foo', 'bar']]) as unknown as object,
80
+ new Set(['foo', 'bar']) as unknown as object,
81
+ ),
82
+ ).toBe(false)
83
+ })
84
+
85
+ it('treats non-plain objects as unequal unless they are the same reference', () => {
86
+ class Example {
87
+ constructor(public value: string) {}
88
+ }
89
+
90
+ expect(isDeepEqual(new Example('a'), new Example('a'))).toBe(false)
91
+ const instance = new Example('a')
92
+ expect(isDeepEqual(instance, instance)).toBe(true)
93
+ })
94
+ })
95
+ })