@teardown/react-native 2.0.22 → 2.0.24

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teardown/react-native",
3
- "version": "2.0.22",
3
+ "version": "2.0.24",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -37,7 +37,6 @@ export type ApiClientOptions = {
37
37
  * @returns The options for the request.
38
38
  */
39
39
  onRequest?: (endpoint: IngestApi.Endpoints, options: IngestApi.RequestOptions) => Promise<IngestApi.RequestOptions>;
40
-
41
40
  /**
42
41
  * The URL of the ingest API.
43
42
  * @default https://ingest.teardown.dev
@@ -31,9 +31,9 @@ export enum DevicePlatformEnum {
31
31
  OTHER = "OTHER",
32
32
  }
33
33
 
34
- export type DeviceClientOptions = {
34
+ export interface DeviceClientOptions {
35
35
  adapter: DeviceInfoAdapter;
36
- };
36
+ }
37
37
  export class DeviceClient {
38
38
  private logger: Logger;
39
39
  private storage: SupportedStorage;
@@ -1,4 +1,4 @@
1
- import { describe, test, expect, beforeEach, mock } from "bun:test";
1
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
2
2
  import { EventEmitter } from "eventemitter3";
3
3
  import { ForceUpdateClient, IdentifyVersionStatusEnum, VERSION_STATUS_STORAGE_KEY } from "./force-update.client";
4
4
 
@@ -21,10 +21,11 @@ function createMockIdentityClient(initialState?: IdentifyState) {
21
21
  const emitter = new EventEmitter<IdentifyStateChangeEvents>();
22
22
  let identifyCallCount = 0;
23
23
  let currentState: IdentifyState = initialState ?? { type: "unidentified" };
24
- let nextIdentifyResult: { success: boolean; data?: { version_info: { status: IdentifyVersionStatusEnum } } } | null = {
25
- success: true,
26
- data: { version_info: { status: IdentifyVersionStatusEnum.UP_TO_DATE } },
27
- };
24
+ let nextIdentifyResult: { success: boolean; data?: { version_info: { status: IdentifyVersionStatusEnum } } } | null =
25
+ {
26
+ success: true,
27
+ data: { version_info: { status: IdentifyVersionStatusEnum.UP_TO_DATE } },
28
+ };
28
29
 
29
30
  return {
30
31
  emitter,
@@ -43,7 +44,10 @@ function createMockIdentityClient(initialState?: IdentifyState) {
43
44
  currentState = {
44
45
  type: "identified",
45
46
  session: { session_id: "s1", device_id: "d1", user_id: "p1", token: "t1" },
46
- version_info: { status: nextIdentifyResult.data?.version_info.status ?? IdentifyVersionStatusEnum.UP_TO_DATE, update: null },
47
+ version_info: {
48
+ status: nextIdentifyResult.data?.version_info.status ?? IdentifyVersionStatusEnum.UP_TO_DATE,
49
+ update: null,
50
+ },
47
51
  };
48
52
  emitter.emit("IDENTIFY_STATE_CHANGED", currentState);
49
53
  return nextIdentifyResult;
@@ -58,10 +62,10 @@ function createMockIdentityClient(initialState?: IdentifyState) {
58
62
  function createMockLoggingClient() {
59
63
  return {
60
64
  createLogger: () => ({
61
- info: () => { },
62
- warn: () => { },
63
- error: () => { },
64
- debug: () => { },
65
+ info: () => {},
66
+ warn: () => {},
67
+ error: () => {},
68
+ debug: () => {},
65
69
  }),
66
70
  };
67
71
  }
@@ -93,11 +97,7 @@ describe("ForceUpdateClient", () => {
93
97
  const mockLogging = createMockLoggingClient();
94
98
  const mockStorage = createMockStorageClient();
95
99
 
96
- const client = new ForceUpdateClient(
97
- mockLogging as never,
98
- mockStorage as never,
99
- mockIdentity as never
100
- );
100
+ const client = new ForceUpdateClient(mockLogging as never, mockStorage as never, mockIdentity as never);
101
101
  client.initialize();
102
102
 
103
103
  // Should immediately have update_required status from initialization
@@ -111,11 +111,7 @@ describe("ForceUpdateClient", () => {
111
111
  const mockLogging = createMockLoggingClient();
112
112
  const mockStorage = createMockStorageClient();
113
113
 
114
- const client = new ForceUpdateClient(
115
- mockLogging as never,
116
- mockStorage as never,
117
- mockIdentity as never
118
- );
114
+ const client = new ForceUpdateClient(mockLogging as never, mockStorage as never, mockIdentity as never);
119
115
  client.initialize();
120
116
 
121
117
  // Should stay in initializing since not yet identified
@@ -135,11 +131,7 @@ describe("ForceUpdateClient", () => {
135
131
 
136
132
  const statusChanges: VersionStatus[] = [];
137
133
 
138
- const client = new ForceUpdateClient(
139
- mockLogging as never,
140
- mockStorage as never,
141
- mockIdentity as never
142
- );
134
+ const client = new ForceUpdateClient(mockLogging as never, mockStorage as never, mockIdentity as never);
143
135
  client.initialize();
144
136
 
145
137
  // Subscribe after construction to verify initial status was set
@@ -166,18 +158,15 @@ describe("ForceUpdateClient", () => {
166
158
  const mockLogging = createMockLoggingClient();
167
159
  const mockStorage = createMockStorageClient();
168
160
 
169
- const client = new ForceUpdateClient(
170
- mockLogging as never,
171
- mockStorage as never,
172
- mockIdentity as never,
173
- { throttleMs: 0, checkCooldownMs: 0 }
174
- );
161
+ const client = new ForceUpdateClient(mockLogging as never, mockStorage as never, mockIdentity as never, {
162
+ checkOnForeground: true,
163
+ });
175
164
  client.initialize();
176
165
 
177
166
  const statusChanges: VersionStatus[] = [];
178
167
  client.onVersionStatusChange((status) => statusChanges.push(status));
179
168
 
180
- // Trigger identify via state change
169
+ // Trigger identify via identify state change
181
170
  mockIdentity.emitter.emit("IDENTIFY_STATE_CHANGED", { type: "identifying" });
182
171
  mockIdentity.emitter.emit("IDENTIFY_STATE_CHANGED", {
183
172
  type: "identified",
@@ -203,12 +192,9 @@ describe("ForceUpdateClient", () => {
203
192
  data: { version_info: { status: IdentifyVersionStatusEnum.UPDATE_REQUIRED } },
204
193
  });
205
194
 
206
- const client = new ForceUpdateClient(
207
- mockLogging as never,
208
- mockStorage as never,
209
- mockIdentity as never,
210
- { throttleMs: 0, checkCooldownMs: 0 }
211
- );
195
+ const client = new ForceUpdateClient(mockLogging as never, mockStorage as never, mockIdentity as never, {
196
+ checkOnForeground: true,
197
+ });
212
198
  client.initialize();
213
199
 
214
200
  const statusChanges: VersionStatus[] = [];
@@ -238,11 +224,7 @@ describe("ForceUpdateClient", () => {
238
224
  const mockLogging = createMockLoggingClient();
239
225
  const mockStorage = createMockStorageClient();
240
226
 
241
- const client = new ForceUpdateClient(
242
- mockLogging as never,
243
- mockStorage as never,
244
- mockIdentity as never
245
- );
227
+ const client = new ForceUpdateClient(mockLogging as never, mockStorage as never, mockIdentity as never);
246
228
  client.initialize();
247
229
 
248
230
  const statusChanges: VersionStatus[] = [];
@@ -266,11 +248,7 @@ describe("ForceUpdateClient", () => {
266
248
  const mockLogging = createMockLoggingClient();
267
249
  const mockStorage = createMockStorageClient();
268
250
 
269
- const client = new ForceUpdateClient(
270
- mockLogging as never,
271
- mockStorage as never,
272
- mockIdentity as never
273
- );
251
+ const client = new ForceUpdateClient(mockLogging as never, mockStorage as never, mockIdentity as never);
274
252
  client.initialize();
275
253
 
276
254
  expect(mockAppStateListeners).toHaveLength(1);
@@ -281,7 +259,7 @@ describe("ForceUpdateClient", () => {
281
259
  });
282
260
  });
283
261
 
284
- describe("throttle and cooldown", () => {
262
+ describe("checkIntervalMs", () => {
285
263
  test("skips version check when identify returns null", async () => {
286
264
  const mockIdentity = createMockIdentityClient();
287
265
  const mockLogging = createMockLoggingClient();
@@ -289,12 +267,9 @@ describe("ForceUpdateClient", () => {
289
267
 
290
268
  mockIdentity.setNextIdentifyResult(null);
291
269
 
292
- const client = new ForceUpdateClient(
293
- mockLogging as never,
294
- mockStorage as never,
295
- mockIdentity as never,
296
- { throttleMs: 0, checkCooldownMs: 0 }
297
- );
270
+ const client = new ForceUpdateClient(mockLogging as never, mockStorage as never, mockIdentity as never, {
271
+ checkIntervalMs: 0,
272
+ });
298
273
  client.initialize();
299
274
 
300
275
  const statusChanges: VersionStatus[] = [];
@@ -311,17 +286,14 @@ describe("ForceUpdateClient", () => {
311
286
  client.shutdown();
312
287
  });
313
288
 
314
- test("checkCooldownMs -1 disables version checking entirely", async () => {
289
+ test("checkIntervalMs -1 disables version checking entirely", async () => {
315
290
  const mockIdentity = createMockIdentityClient();
316
291
  const mockLogging = createMockLoggingClient();
317
292
  const mockStorage = createMockStorageClient();
318
293
 
319
- const client = new ForceUpdateClient(
320
- mockLogging as never,
321
- mockStorage as never,
322
- mockIdentity as never,
323
- { throttleMs: 0, checkCooldownMs: -1 }
324
- );
294
+ const client = new ForceUpdateClient(mockLogging as never, mockStorage as never, mockIdentity as never, {
295
+ checkIntervalMs: -1,
296
+ });
325
297
  client.initialize();
326
298
 
327
299
  const foregroundHandler = mockAppStateListeners[0];
@@ -336,17 +308,47 @@ describe("ForceUpdateClient", () => {
336
308
  client.shutdown();
337
309
  });
338
310
 
339
- test("throttle prevents rapid foreground checks", async () => {
311
+ test("interval prevents checks too soon after successful check", async () => {
340
312
  const mockIdentity = createMockIdentityClient();
341
313
  const mockLogging = createMockLoggingClient();
342
314
  const mockStorage = createMockStorageClient();
343
315
 
344
- const client = new ForceUpdateClient(
345
- mockLogging as never,
346
- mockStorage as never,
347
- mockIdentity as never,
348
- { throttleMs: 1000, checkCooldownMs: 0 }
349
- );
316
+ const client = new ForceUpdateClient(mockLogging as never, mockStorage as never, mockIdentity as never, {
317
+ checkOnForeground: true,
318
+ checkIntervalMs: 100_000,
319
+ });
320
+ client.initialize();
321
+
322
+ const foregroundHandler = mockAppStateListeners[0];
323
+
324
+ // First foreground - should trigger check
325
+ await foregroundHandler("active");
326
+ await new Promise((r) => setTimeout(r, 10));
327
+
328
+ const callsAfterFirst = mockIdentity.getIdentifyCallCount();
329
+
330
+ // Second foreground immediately (within interval window)
331
+ await foregroundHandler("active");
332
+ await new Promise((r) => setTimeout(r, 10));
333
+
334
+ const callsAfterSecond = mockIdentity.getIdentifyCallCount();
335
+
336
+ // Only first call should have triggered identify (interval blocks second)
337
+ expect(callsAfterFirst).toBe(1);
338
+ expect(callsAfterSecond).toBe(1);
339
+
340
+ client.shutdown();
341
+ });
342
+
343
+ test("checkOnForeground: true respects interval", async () => {
344
+ const mockIdentity = createMockIdentityClient();
345
+ const mockLogging = createMockLoggingClient();
346
+ const mockStorage = createMockStorageClient();
347
+
348
+ const client = new ForceUpdateClient(mockLogging as never, mockStorage as never, mockIdentity as never, {
349
+ checkOnForeground: true,
350
+ checkIntervalMs: 100_000,
351
+ });
350
352
  client.initialize();
351
353
 
352
354
  const foregroundHandler = mockAppStateListeners[0];
@@ -357,30 +359,78 @@ describe("ForceUpdateClient", () => {
357
359
 
358
360
  const callsAfterFirst = mockIdentity.getIdentifyCallCount();
359
361
 
360
- // Second foreground immediately (within throttle window)
362
+ // Second foreground immediately (within interval window)
361
363
  await foregroundHandler("active");
362
364
  await new Promise((r) => setTimeout(r, 10));
363
365
 
364
366
  const callsAfterSecond = mockIdentity.getIdentifyCallCount();
365
367
 
366
- // Should only have one identify call due to throttle
368
+ // Only first should trigger - interval blocks second even with checkOnForeground: true
367
369
  expect(callsAfterFirst).toBe(1);
368
370
  expect(callsAfterSecond).toBe(1);
369
371
 
370
372
  client.shutdown();
371
373
  });
372
374
 
373
- test("cooldown prevents redundant checks after recent success", async () => {
375
+ test("checkOnForeground: false disables foreground checking entirely", async () => {
376
+ const mockIdentity = createMockIdentityClient();
377
+ const mockLogging = createMockLoggingClient();
378
+ const mockStorage = createMockStorageClient();
379
+
380
+ const client = new ForceUpdateClient(mockLogging as never, mockStorage as never, mockIdentity as never, {
381
+ checkOnForeground: false,
382
+ checkIntervalMs: 0, // Would normally allow every check
383
+ });
384
+ client.initialize();
385
+
386
+ const foregroundHandler = mockAppStateListeners[0];
387
+
388
+ // Foreground should not trigger check when checkOnForeground is false
389
+ await foregroundHandler("active");
390
+ await new Promise((r) => setTimeout(r, 10));
391
+
392
+ expect(mockIdentity.getIdentifyCallCount()).toBe(0);
393
+
394
+ client.shutdown();
395
+ });
396
+
397
+ test("checkIntervalMs 0 checks on every foreground", async () => {
374
398
  const mockIdentity = createMockIdentityClient();
375
399
  const mockLogging = createMockLoggingClient();
376
400
  const mockStorage = createMockStorageClient();
377
401
 
378
- const client = new ForceUpdateClient(
379
- mockLogging as never,
380
- mockStorage as never,
381
- mockIdentity as never,
382
- { throttleMs: 0, checkCooldownMs: 5000 }
383
- );
402
+ const client = new ForceUpdateClient(mockLogging as never, mockStorage as never, mockIdentity as never, {
403
+ checkOnForeground: true,
404
+ checkIntervalMs: 0,
405
+ });
406
+ client.initialize();
407
+
408
+ const foregroundHandler = mockAppStateListeners[0];
409
+
410
+ // First foreground
411
+ await foregroundHandler("active");
412
+ await new Promise((r) => setTimeout(r, 10));
413
+
414
+ // Second foreground immediately
415
+ await foregroundHandler("active");
416
+ await new Promise((r) => setTimeout(r, 10));
417
+
418
+ // Both should trigger since interval is 0
419
+ expect(mockIdentity.getIdentifyCallCount()).toBe(2);
420
+
421
+ client.shutdown();
422
+ });
423
+
424
+ test("values below 30s are clamped to 30s minimum", async () => {
425
+ const mockIdentity = createMockIdentityClient();
426
+ const mockLogging = createMockLoggingClient();
427
+ const mockStorage = createMockStorageClient();
428
+
429
+ // Set interval to 10ms (below 30s minimum)
430
+ const client = new ForceUpdateClient(mockLogging as never, mockStorage as never, mockIdentity as never, {
431
+ checkOnForeground: true,
432
+ checkIntervalMs: 10,
433
+ });
384
434
  client.initialize();
385
435
 
386
436
  const foregroundHandler = mockAppStateListeners[0];
@@ -391,13 +441,13 @@ describe("ForceUpdateClient", () => {
391
441
 
392
442
  const callsAfterFirst = mockIdentity.getIdentifyCallCount();
393
443
 
394
- // Second foreground (within cooldown window)
444
+ // Second foreground immediately - should be blocked by 30s minimum
395
445
  await foregroundHandler("active");
396
446
  await new Promise((r) => setTimeout(r, 10));
397
447
 
398
448
  const callsAfterSecond = mockIdentity.getIdentifyCallCount();
399
449
 
400
- // Only first call should have triggered identify (cooldown blocks second)
450
+ // Only first call should have triggered (30s minimum enforced)
401
451
  expect(callsAfterFirst).toBe(1);
402
452
  expect(callsAfterSecond).toBe(1);
403
453
 
@@ -414,11 +464,7 @@ describe("ForceUpdateClient", () => {
414
464
  // Pre-populate storage with stale "checking" state
415
465
  mockStorage.getStorage().set(VERSION_STATUS_STORAGE_KEY, JSON.stringify({ type: "checking" }));
416
466
 
417
- const client = new ForceUpdateClient(
418
- mockLogging as never,
419
- mockStorage as never,
420
- mockIdentity as never
421
- );
467
+ const client = new ForceUpdateClient(mockLogging as never, mockStorage as never, mockIdentity as never);
422
468
  client.initialize();
423
469
 
424
470
  // Should reset to initializing, not stay in checking
@@ -435,11 +481,7 @@ describe("ForceUpdateClient", () => {
435
481
  // Pre-populate storage with stale "initializing" state
436
482
  mockStorage.getStorage().set(VERSION_STATUS_STORAGE_KEY, JSON.stringify({ type: "initializing" }));
437
483
 
438
- const client = new ForceUpdateClient(
439
- mockLogging as never,
440
- mockStorage as never,
441
- mockIdentity as never
442
- );
484
+ const client = new ForceUpdateClient(mockLogging as never, mockStorage as never, mockIdentity as never);
443
485
  client.initialize();
444
486
 
445
487
  // Should reset to initializing (which it already is, but storage should be cleared)
@@ -456,11 +498,7 @@ describe("ForceUpdateClient", () => {
456
498
  // Pre-populate storage with valid state
457
499
  mockStorage.getStorage().set(VERSION_STATUS_STORAGE_KEY, JSON.stringify({ type: "up_to_date" }));
458
500
 
459
- const client = new ForceUpdateClient(
460
- mockLogging as never,
461
- mockStorage as never,
462
- mockIdentity as never
463
- );
501
+ const client = new ForceUpdateClient(mockLogging as never, mockStorage as never, mockIdentity as never);
464
502
  client.initialize();
465
503
 
466
504
  // Should keep up_to_date from storage
@@ -477,11 +515,7 @@ describe("ForceUpdateClient", () => {
477
515
  // Pre-populate storage with valid state
478
516
  mockStorage.getStorage().set(VERSION_STATUS_STORAGE_KEY, JSON.stringify({ type: "update_required" }));
479
517
 
480
- const client = new ForceUpdateClient(
481
- mockLogging as never,
482
- mockStorage as never,
483
- mockIdentity as never
484
- );
518
+ const client = new ForceUpdateClient(mockLogging as never, mockStorage as never, mockIdentity as never);
485
519
  client.initialize();
486
520
 
487
521
  // Should keep update_required from storage
@@ -503,11 +537,7 @@ describe("ForceUpdateClient", () => {
503
537
  const mockLogging = createMockLoggingClient();
504
538
  const mockStorage = createMockStorageClient();
505
539
 
506
- const client = new ForceUpdateClient(
507
- mockLogging as never,
508
- mockStorage as never,
509
- mockIdentity as never
510
- );
540
+ const client = new ForceUpdateClient(mockLogging as never, mockStorage as never, mockIdentity as never);
511
541
  client.initialize();
512
542
 
513
543
  const statusChanges: VersionStatus[] = [];
@@ -29,7 +29,6 @@ export enum IdentifyVersionStatusEnum {
29
29
  DISABLED = "DISABLED",
30
30
  }
31
31
 
32
-
33
32
  export const InitializingVersionStatusSchema = z.object({ type: z.literal("initializing") });
34
33
  export const CheckingVersionStatusSchema = z.object({ type: z.literal("checking") });
35
34
  export const UpToDateVersionStatusSchema = z.object({ type: z.literal("up_to_date") });
@@ -64,43 +63,38 @@ export type UpdateRequiredVersionStatus = z.infer<typeof UpdateRequiredVersionSt
64
63
  export type DisabledVersionStatus = z.infer<typeof DisabledVersionStatusSchema>;
65
64
  export type VersionStatus = z.infer<typeof VersionStatusSchema>;
66
65
 
67
- export type VersionStatusChangeEvents = {
66
+ export interface VersionStatusChangeEvents {
68
67
  VERSION_STATUS_CHANGED: (status: VersionStatus) => void;
69
- };
68
+ }
70
69
 
71
70
  export type ForceUpdateClientOptions = {
72
- /**
73
- * Minimum time (ms) between foreground transitions to prevent rapid-fire checks.
74
- * Measured from the last time the app came to foreground.
75
- * Prevents checking when user quickly switches apps back and forth.
76
- * Default: 30000 (30 seconds)
77
- *
78
- * Special values:
79
- * - -1: Disable throttling, check on every foreground (respects checkCooldownMs)
80
- *
81
- * Example: If throttleMs is 30s and user backgrounds then foregrounds the app
82
- * twice within 20s, only the first transition triggers a check.
83
- */
84
- throttleMs?: number;
85
- /**
86
- * Minimum time (ms) since the last successful version check before checking again.
87
- * Measured from when the last check completed successfully (not when it started).
88
- * Prevents unnecessary API calls after we already have fresh version data.
71
+ /**
72
+ * Minimum time (ms) between version checks.
89
73
  * Default: 300000 (5 minutes)
90
- *
74
+ *
75
+ * Values below 30 seconds are clamped to 30 seconds to prevent excessive API calls.
76
+ *
91
77
  * Special values:
92
- * - 0: Disable cooldown, check on every foreground (respects throttleMs)
93
- * - -1: Disable all automatic version checking
94
- *
95
- * Example: If checkCooldownMs is 5min and a check completes at 12:00pm,
78
+ * - 0: Check on every foreground (no interval)
79
+ * - -1: Disable automatic version checking entirely
80
+ *
81
+ * Example: If checkIntervalMs is 5min and a check completes at 12:00pm,
96
82
  * no new checks occur until 12:05pm, even if user foregrounds the app multiple times.
97
83
  */
98
- checkCooldownMs?: number;
84
+ checkIntervalMs?: number;
85
+ /** Check version when app comes to foreground, respecting checkIntervalMs (default: true) */
86
+ checkOnForeground?: boolean;
87
+ /** If true, check version even when not identified by using anonymous device identification (default: false) */
88
+ identifyAnonymousDevice?: boolean;
99
89
  };
100
90
 
91
+ /** Hard minimum interval between checks to prevent excessive API calls */
92
+ const MIN_CHECK_INTERVAL_MS = 30_000; // 30 seconds
93
+
101
94
  const DEFAULT_OPTIONS: Required<ForceUpdateClientOptions> = {
102
- throttleMs: 30_000, // 30 seconds
103
- checkCooldownMs: 300_000, // 5 minutes
95
+ checkIntervalMs: 300_000, // 5 minutes
96
+ checkOnForeground: true,
97
+ identifyAnonymousDevice: false,
104
98
  };
105
99
 
106
100
  export const VERSION_STATUS_STORAGE_KEY = "version_status";
@@ -111,7 +105,6 @@ export class ForceUpdateClient {
111
105
  private unsubscribe: (() => void) | null = null;
112
106
  private appStateSubscription: NativeEventSubscription | null = null;
113
107
  private lastCheckTime: number | null = null;
114
- private lastForegroundTime: number | null = null;
115
108
  private initialized = false;
116
109
 
117
110
  private readonly logger: Logger;
@@ -127,7 +120,6 @@ export class ForceUpdateClient {
127
120
  this.logger = logging.createLogger({ name: "ForceUpdateClient" });
128
121
  this.storage = storage.createStorage("version");
129
122
  this.options = { ...DEFAULT_OPTIONS, ...options };
130
- // Don't initialize here - defer to initialize()
131
123
  }
132
124
 
133
125
  initialize(): void {
@@ -137,7 +129,6 @@ export class ForceUpdateClient {
137
129
  }
138
130
  this.initialized = true;
139
131
 
140
- // Load from storage, subscribe to events, and sync with current identity state
141
132
  this.versionStatus = this.getVersionStatusFromStorage();
142
133
  this.logger.debug(`Initialized with version status: ${this.versionStatus.type}`);
143
134
  this.subscribeToIdentity();
@@ -149,7 +140,9 @@ export class ForceUpdateClient {
149
140
  const currentState = this.identity.getIdentifyState();
150
141
  this.logger.debug(`Current identity state during init: ${currentState.type}`);
151
142
  if (currentState.type === "identified") {
152
- this.logger.debug(`Identity already identified, syncing version status from: ${currentState.version_info.status}`);
143
+ this.logger.debug(
144
+ `Identity already identified, syncing version status from: ${currentState.version_info.status}`
145
+ );
153
146
  this.updateFromVersionStatus(currentState.version_info.status);
154
147
  } else {
155
148
  this.logger.debug(`Identity not yet identified (${currentState.type}), waiting for identify event`);
@@ -157,25 +150,30 @@ export class ForceUpdateClient {
157
150
  }
158
151
 
159
152
  private getVersionStatusFromStorage(): VersionStatus {
160
- const stored = this.storage.getItem(VERSION_STATUS_STORAGE_KEY);
153
+ try {
154
+ const stored = this.storage.getItem(VERSION_STATUS_STORAGE_KEY);
161
155
 
162
- if (stored == null) {
163
- this.logger.debug("No stored version status, returning initializing");
164
- return InitializingVersionStatusSchema.parse({ type: "initializing" });
165
- }
156
+ if (stored == null) {
157
+ this.logger.debug("No stored version status, returning initializing");
158
+ return InitializingVersionStatusSchema.parse({ type: "initializing" });
159
+ }
166
160
 
167
- const parsed = VersionStatusSchema.parse(JSON.parse(stored));
168
- this.logger.debug(`Parsed version status from storage: ${parsed.type}`);
161
+ const parsed = VersionStatusSchema.parse(JSON.parse(stored));
162
+ this.logger.debug(`Parsed version status from storage: ${parsed.type}`);
163
+
164
+ // "checking" and "initializing" are transient states - if we restore them, reset to initializing
165
+ // This can happen if the app was killed during a version check
166
+ if (parsed.type === "checking" || parsed.type === "initializing") {
167
+ this.logger.debug(`Found stale '${parsed.type}' state in storage, resetting to initializing`);
168
+ this.storage.removeItem(VERSION_STATUS_STORAGE_KEY);
169
+ return InitializingVersionStatusSchema.parse({ type: "initializing" });
170
+ }
169
171
 
170
- // "checking" and "initializing" are transient states - if we restore them, reset to initializing
171
- // This can happen if the app was killed during a version check
172
- if (parsed.type === "checking" || parsed.type === "initializing") {
173
- this.logger.debug(`Found stale '${parsed.type}' state in storage, resetting to initializing`);
174
- this.storage.removeItem(VERSION_STATUS_STORAGE_KEY);
172
+ return parsed;
173
+ } catch (error) {
174
+ this.logger.debugError("Error getting version status from storage", { error });
175
175
  return InitializingVersionStatusSchema.parse({ type: "initializing" });
176
176
  }
177
-
178
- return parsed;
179
177
  }
180
178
 
181
179
  private saveVersionStatusToStorage(status: VersionStatus): void {
@@ -232,29 +230,22 @@ export class ForceUpdateClient {
232
230
  if (nextState === "active") {
233
231
  this.logger.debug("App state changed to active");
234
232
 
235
- // If checkCooldownMs is -1, disable checking entirely
236
- if (this.options.checkCooldownMs === -1) {
237
- this.logger.debug("Version checking disabled (checkCooldownMs = -1)");
233
+ // If checkIntervalMs is -1, disable checking entirely
234
+ if (this.options.checkIntervalMs === -1) {
235
+ this.logger.debug("Version checking disabled (checkIntervalMs = -1)");
238
236
  return;
239
237
  }
240
238
 
241
239
  const now = Date.now();
242
240
 
243
- // If throttleMs is -1, disable throttling (always pass)
244
- // Otherwise, check if enough time has passed since last foreground
245
- const throttleOk = this.options.throttleMs === -1
246
- || !this.lastForegroundTime
247
- || now - this.lastForegroundTime >= this.options.throttleMs;
248
-
249
- // If checkCooldownMs is 0, always allow check (no cooldown)
250
- // Otherwise, check if enough time has passed since last successful check
251
- const cooldownOk = this.options.checkCooldownMs === 0
252
- || !this.lastCheckTime
253
- || now - this.lastCheckTime >= this.options.checkCooldownMs;
241
+ // Calculate effective interval (clamp to minimum unless 0 for "always check")
242
+ const effectiveInterval =
243
+ this.options.checkIntervalMs === 0 ? 0 : Math.max(this.options.checkIntervalMs, MIN_CHECK_INTERVAL_MS);
254
244
 
255
- this.lastForegroundTime = now;
245
+ // Check if enough time has passed since last successful check
246
+ const canCheck = effectiveInterval === 0 || !this.lastCheckTime || now - this.lastCheckTime >= effectiveInterval;
256
247
 
257
- if (throttleOk && cooldownOk) {
248
+ if (this.options.checkOnForeground && canCheck) {
258
249
  this.checkVersionOnForeground();
259
250
  }
260
251
  }
@@ -304,5 +295,4 @@ export class ForceUpdateClient {
304
295
  }
305
296
  this.emitter.removeAllListeners("VERSION_STATUS_CHANGED");
306
297
  }
307
-
308
298
  }
@@ -3,7 +3,7 @@ import { describe, expect, mock, test, beforeEach } from "bun:test";
3
3
  // Mock react-native before any imports that use it
4
4
  mock.module("react-native", () => ({
5
5
  AppState: {
6
- addEventListener: () => ({ remove: () => { } }),
6
+ addEventListener: () => ({ remove: () => {} }),
7
7
  },
8
8
  }));
9
9
 
@@ -25,9 +25,13 @@ function createMockLoggingClient() {
25
25
  warn: (message: string, ...args: unknown[]) => logs.push({ level: "warn", message, args }),
26
26
  error: (message: string, ...args: unknown[]) => logs.push({ level: "error", message, args }),
27
27
  debug: (message: string, ...args: unknown[]) => logs.push({ level: "debug", message, args }),
28
+ debugInfo: (message: string, ...args: unknown[]) => logs.push({ level: "debugInfo", message, args }),
29
+ debugError: (message: string, ...args: unknown[]) => logs.push({ level: "debugError", message, args }),
28
30
  }),
29
31
  getLogs: () => logs,
30
- clearLogs: () => { logs.length = 0; },
32
+ clearLogs: () => {
33
+ logs.length = 0;
34
+ },
31
35
  };
32
36
  }
33
37
 
@@ -48,19 +52,23 @@ function createMockUtilsClient() {
48
52
  let uuidCounter = 0;
49
53
  return {
50
54
  generateRandomUUID: async () => `mock-uuid-${++uuidCounter}`,
51
- resetCounter: () => { uuidCounter = 0; },
55
+ resetCounter: () => {
56
+ uuidCounter = 0;
57
+ },
52
58
  };
53
59
  }
54
60
 
55
- function createMockDeviceClient(overrides: Partial<{
56
- deviceId: string;
57
- timestamp: string;
58
- application: { name: string; version: string; build: string; bundle_id: string };
59
- hardware: { brand: string; model: string; device_type: string };
60
- os: { name: string; version: string };
61
- notifications: { push_token: string | null; platform: string | null };
62
- update: null;
63
- }> = {}) {
61
+ function createMockDeviceClient(
62
+ overrides: Partial<{
63
+ deviceId: string;
64
+ timestamp: string;
65
+ application: { name: string; version: string; build: string; bundle_id: string };
66
+ hardware: { brand: string; model: string; device_type: string };
67
+ os: { name: string; version: string };
68
+ notifications: { push_token: string | null; platform: string | null };
69
+ update: null;
70
+ }> = {}
71
+ ) {
64
72
  const defaultDeviceInfo = {
65
73
  timestamp: new Date().toISOString(),
66
74
  application: { name: "TestApp", version: "1.0.0", build: "100", bundle_id: "com.test.app" },
@@ -88,17 +96,19 @@ type ApiCallRecord = {
88
96
  };
89
97
  };
90
98
 
91
- function createMockApiClient(options: {
92
- success?: boolean;
93
- versionStatus?: (typeof IdentifyVersionStatusEnum)[keyof typeof IdentifyVersionStatusEnum];
94
- errorStatus?: number;
95
- errorMessage?: string;
96
- sessionId?: string;
97
- deviceId?: string;
98
- user_id?: string;
99
- token?: string;
100
- throwError?: Error;
101
- } = {}) {
99
+ function createMockApiClient(
100
+ options: {
101
+ success?: boolean;
102
+ versionStatus?: (typeof IdentifyVersionStatusEnum)[keyof typeof IdentifyVersionStatusEnum];
103
+ errorStatus?: number;
104
+ errorMessage?: string;
105
+ sessionId?: string;
106
+ deviceId?: string;
107
+ user_id?: string;
108
+ token?: string;
109
+ throwError?: Error;
110
+ } = {}
111
+ ) {
102
112
  const {
103
113
  success = true,
104
114
  versionStatus = IdentifyVersionStatusEnum.UP_TO_DATE,
@@ -152,18 +162,22 @@ function createMockApiClient(options: {
152
162
  },
153
163
  getCalls: () => calls,
154
164
  getLastCall: () => calls[calls.length - 1],
155
- clearCalls: () => { calls.length = 0; },
165
+ clearCalls: () => {
166
+ calls.length = 0;
167
+ },
156
168
  };
157
169
  }
158
170
 
159
171
  // Helper to create a standard client instance
160
- function createTestClient(overrides: {
161
- logging?: ReturnType<typeof createMockLoggingClient>;
162
- storage?: ReturnType<typeof createMockStorageClient>;
163
- utils?: ReturnType<typeof createMockUtilsClient>;
164
- api?: ReturnType<typeof createMockApiClient>;
165
- device?: ReturnType<typeof createMockDeviceClient>;
166
- } = {}) {
172
+ function createTestClient(
173
+ overrides: {
174
+ logging?: ReturnType<typeof createMockLoggingClient>;
175
+ storage?: ReturnType<typeof createMockStorageClient>;
176
+ utils?: ReturnType<typeof createMockUtilsClient>;
177
+ api?: ReturnType<typeof createMockApiClient>;
178
+ device?: ReturnType<typeof createMockDeviceClient>;
179
+ } = {}
180
+ ) {
167
181
  const mockLogging = overrides.logging ?? createMockLoggingClient();
168
182
  const mockStorage = overrides.storage ?? createMockStorageClient();
169
183
  const mockUtils = overrides.utils ?? createMockUtilsClient();
@@ -244,20 +258,26 @@ describe("IdentityClient", () => {
244
258
  expect(client.getIdentifyState().type).toBe("unidentified");
245
259
  });
246
260
 
247
- test("throws error on invalid stored state (corrupt JSON)", () => {
261
+ test("gracefully handles invalid stored state (corrupt JSON)", () => {
248
262
  const mockStorage = createMockStorageClient();
249
263
  mockStorage.getStorage().set(IDENTIFY_STORAGE_KEY, "not-valid-json{{{");
250
264
 
251
265
  const { client } = createTestClient({ storage: mockStorage });
252
- expect(() => loadStateFromStorage(client)).toThrow();
266
+ loadStateFromStorage(client);
267
+
268
+ // Should gracefully fall back to unidentified state
269
+ expect(client.getIdentifyState().type).toBe("unidentified");
253
270
  });
254
271
 
255
- test("throws error on invalid stored state (schema mismatch)", () => {
272
+ test("gracefully handles invalid stored state (schema mismatch)", () => {
256
273
  const mockStorage = createMockStorageClient();
257
274
  mockStorage.getStorage().set(IDENTIFY_STORAGE_KEY, JSON.stringify({ type: "invalid_type" }));
258
275
 
259
276
  const { client } = createTestClient({ storage: mockStorage });
260
- expect(() => loadStateFromStorage(client)).toThrow();
277
+ loadStateFromStorage(client);
278
+
279
+ // Should gracefully fall back to unidentified state
280
+ expect(client.getIdentifyState().type).toBe("unidentified");
261
281
  });
262
282
 
263
283
  test("creates logger with correct name", () => {
@@ -550,7 +570,17 @@ describe("IdentityClient", () => {
550
570
  await client.identify();
551
571
 
552
572
  const lastCall = mockApi.getLastCall();
553
- const device = (lastCall.config.body as { device?: { timestamp: string; application: { name: string; version: string; build: string; bundle_id: string }; os: { name: string; version: string }; hardware: { brand: string; model: string; device_type: string }; update: null } }).device;
573
+ const device = (
574
+ lastCall.config.body as {
575
+ device?: {
576
+ timestamp: string;
577
+ application: { name: string; version: string; build: string; bundle_id: string };
578
+ os: { name: string; version: string };
579
+ hardware: { brand: string; model: string; device_type: string };
580
+ update: null;
581
+ };
582
+ }
583
+ ).device;
554
584
 
555
585
  expect(device?.timestamp).toBe("2024-01-15T10:30:00.000Z");
556
586
  expect(device?.application.name).toBe("MyApp");
@@ -603,12 +633,12 @@ describe("IdentityClient", () => {
603
633
  // Second identify
604
634
  await client.identify();
605
635
 
606
- // Should have debug log about state already being 'identifying' (first setIdentifyState call)
607
- // Then transitions to identified
608
- const debugLogs = mockLogging.getLogs().filter((l) => l.level === "debug");
609
- // The "identifying" to "identifying" won't happen since we start from "identified"
610
- // But we can check that state changes are logged properly
611
- expect(mockLogging.getLogs().some(l => l.level === "debug")).toBe(true);
636
+ // Should have debug logs about state transitions
637
+ // When already identified, identify() will transition: identified -> identifying -> identified
638
+ const debugLogs = mockLogging.getLogs().filter((l) => l.level === "debug" || l.level === "debugInfo");
639
+ expect(debugLogs.length).toBeGreaterThan(0);
640
+ // Check that state transitions are logged
641
+ expect(debugLogs.some((l) => l.message.includes("Identify state"))).toBe(true);
612
642
  });
613
643
  });
614
644
 
@@ -992,7 +1022,9 @@ describe("IdentityClient", () => {
992
1022
 
993
1023
  // Make API slow so we can check intermediate state
994
1024
  let resolveApi: (value: unknown) => void;
995
- const apiPromise = new Promise((resolve) => { resolveApi = resolve; });
1025
+ const apiPromise = new Promise((resolve) => {
1026
+ resolveApi = resolve;
1027
+ });
996
1028
 
997
1029
  mockApi.client = async () => {
998
1030
  await apiPromise;
@@ -1015,7 +1047,7 @@ describe("IdentityClient", () => {
1015
1047
  const identifyPromise = client.identify();
1016
1048
 
1017
1049
  // Check storage while API call is pending
1018
- await new Promise(resolve => setTimeout(resolve, 10));
1050
+ await new Promise((resolve) => setTimeout(resolve, 10));
1019
1051
  const intermediateStored = mockStorage.getStorage().get(IDENTIFY_STORAGE_KEY);
1020
1052
  expect(JSON.parse(intermediateStored!).type).toBe("identifying");
1021
1053
 
@@ -1048,14 +1080,10 @@ describe("IdentityClient", () => {
1048
1080
  test("handles rapid successive identify calls", async () => {
1049
1081
  const { client } = createTestClient();
1050
1082
 
1051
- const results = await Promise.all([
1052
- client.identify(),
1053
- client.identify(),
1054
- client.identify(),
1055
- ]);
1083
+ const results = await Promise.all([client.identify(), client.identify(), client.identify()]);
1056
1084
 
1057
1085
  // All should succeed
1058
- results.forEach(result => {
1086
+ results.forEach((result) => {
1059
1087
  expect(result.success).toBe(true);
1060
1088
  });
1061
1089
 
@@ -1116,4 +1144,4 @@ describe("IdentityClient", () => {
1116
1144
  expect(parsed.session.session_id).toBe("session-with-émojis-🚀-and-üñíçödé");
1117
1145
  });
1118
1146
  });
1119
- });
1147
+ });
@@ -8,13 +8,13 @@ import type { Logger, LoggingClient } from "../logging";
8
8
  import type { StorageClient, SupportedStorage } from "../storage";
9
9
  import type { UtilsClient } from "../utils";
10
10
 
11
- export type Persona = {
11
+ export interface Persona {
12
12
  name?: string | undefined;
13
13
  user_id?: string | undefined;
14
14
  email?: string | undefined;
15
- };
15
+ }
16
16
 
17
- export type IdentityUser = {
17
+ export interface IdentityUser {
18
18
  session_id: string;
19
19
  device_id: string;
20
20
  user_id: string;
@@ -69,9 +69,9 @@ export type UnidentifiedSessionState = z.infer<typeof UnidentifiedSessionStateSc
69
69
  export type IdentifyingSessionState = z.infer<typeof IdentifyingSessionStateSchema>;
70
70
  export type IdentifiedSessionState = z.infer<typeof IdentifiedSessionStateSchema>;
71
71
 
72
- export type IdentifyStateChangeEvents = {
72
+ export interface IdentifyStateChangeEvents {
73
73
  IDENTIFY_STATE_CHANGED: (state: IdentifyState) => void;
74
- };
74
+ }
75
75
 
76
76
  export const IDENTIFY_STORAGE_KEY = "IDENTIFY_STATE";
77
77
 
@@ -8,9 +8,9 @@ const LOG_LEVEL_PRIORITY: Record<LogLevel, number> = {
8
8
  verbose: 4,
9
9
  };
10
10
 
11
- export type LoggingClientOptions = {
11
+ export interface LoggingClientOptions {
12
12
  logLevel?: LogLevel;
13
- };
13
+ }
14
14
 
15
15
  export class LoggingClient {
16
16
  private logLevel: LogLevel;
@@ -42,12 +42,12 @@ export class LoggingClient {
42
42
  /**
43
43
  * Configuration options for logger creation
44
44
  */
45
- export type LoggerOptions = {
45
+ export interface LoggerOptions {
46
46
  /** Logger name used in log prefixes */
47
47
  name: string;
48
48
  /** Reference to parent LoggingClient for log level checks */
49
49
  loggingClient: LoggingClient;
50
- };
50
+ }
51
51
 
52
52
  export class Logger {
53
53
  /** Bound console methods to preserve call site */
@@ -2,9 +2,9 @@ import { createContext, useContext } from "react";
2
2
 
3
3
  import type { TeardownCore } from "../teardown.core";
4
4
 
5
- export type TeardownContextType = {
5
+ export interface TeardownContextType {
6
6
  core: TeardownCore;
7
- };
7
+ }
8
8
 
9
9
  export const TeardownContext = createContext<TeardownContextType | null>(null);
10
10
 
@@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
2
2
  import type { VersionStatus } from "../clients/force-update";
3
3
  import { useTeardown } from "../contexts/teardown.context";
4
4
 
5
- export type UseForceUpdateResult = {
5
+ export interface UseForceUpdateResult {
6
6
  /**
7
7
  * The current version status.
8
8
  */
@@ -19,7 +19,7 @@ export type UseForceUpdateResult = {
19
19
  * Whether the the current version is out of date and is forced to be updated.
20
20
  */
21
21
  isUpdateRequired: boolean;
22
- };
22
+ }
23
23
 
24
24
  export const useForceUpdate = (): UseForceUpdateResult => {
25
25
  const { core } = useTeardown();
@@ -2,28 +2,26 @@ import { useEffect, useState } from "react";
2
2
  import type { Session } from "../clients/identity/identity.client";
3
3
  import { useTeardown } from "../contexts/teardown.context";
4
4
 
5
- export type UseSessionResult = Session | null
5
+ export type UseSessionResult = Session | null;
6
6
 
7
7
  export const useSession = (): UseSessionResult => {
8
- const { core } = useTeardown();
8
+ const { core } = useTeardown();
9
9
 
10
- const [session, setSession] = useState<Session | null>(
11
- core.identity.getSessionState()
12
- );
10
+ const [session, setSession] = useState<Session | null>(core.identity.getSessionState());
13
11
 
14
- useEffect(() => {
15
- const unsubscribe = core.identity.onIdentifyStateChange((state) => {
16
- switch (state.type) {
17
- case "identified":
18
- setSession(state.session);
19
- break;
20
- case "unidentified":
21
- setSession(null);
22
- break;
23
- }
24
- });
25
- return unsubscribe;
26
- }, [core.identity]);
12
+ useEffect(() => {
13
+ const unsubscribe = core.identity.onIdentifyStateChange((state) => {
14
+ switch (state.type) {
15
+ case "identified":
16
+ setSession(state.session);
17
+ break;
18
+ case "unidentified":
19
+ setSession(null);
20
+ break;
21
+ }
22
+ });
23
+ return unsubscribe;
24
+ }, [core.identity]);
27
25
 
28
- return session;
29
- };
26
+ return session;
27
+ };