@teardown/react-native 2.0.23 → 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.23",
3
+ "version": "2.0.24",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -259,7 +259,7 @@ describe("ForceUpdateClient", () => {
259
259
  });
260
260
  });
261
261
 
262
- describe("throttle and cooldown", () => {
262
+ describe("checkIntervalMs", () => {
263
263
  test("skips version check when identify returns null", async () => {
264
264
  const mockIdentity = createMockIdentityClient();
265
265
  const mockLogging = createMockLoggingClient();
@@ -268,8 +268,7 @@ describe("ForceUpdateClient", () => {
268
268
  mockIdentity.setNextIdentifyResult(null);
269
269
 
270
270
  const client = new ForceUpdateClient(mockLogging as never, mockStorage as never, mockIdentity as never, {
271
- throttleMs: 0,
272
- checkCooldownMs: 0,
271
+ checkIntervalMs: 0,
273
272
  });
274
273
  client.initialize();
275
274
 
@@ -287,14 +286,13 @@ describe("ForceUpdateClient", () => {
287
286
  client.shutdown();
288
287
  });
289
288
 
290
- test("checkCooldownMs -1 disables version checking entirely", async () => {
289
+ test("checkIntervalMs -1 disables version checking entirely", async () => {
291
290
  const mockIdentity = createMockIdentityClient();
292
291
  const mockLogging = createMockLoggingClient();
293
292
  const mockStorage = createMockStorageClient();
294
293
 
295
294
  const client = new ForceUpdateClient(mockLogging as never, mockStorage as never, mockIdentity as never, {
296
- throttleMs: 0,
297
- checkCooldownMs: -1,
295
+ checkIntervalMs: -1,
298
296
  });
299
297
  client.initialize();
300
298
 
@@ -310,47 +308,46 @@ describe("ForceUpdateClient", () => {
310
308
  client.shutdown();
311
309
  });
312
310
 
313
- test("throttle prevents rapid foreground checks", async () => {
311
+ test("interval prevents checks too soon after successful check", async () => {
314
312
  const mockIdentity = createMockIdentityClient();
315
313
  const mockLogging = createMockLoggingClient();
316
314
  const mockStorage = createMockStorageClient();
317
315
 
318
316
  const client = new ForceUpdateClient(mockLogging as never, mockStorage as never, mockIdentity as never, {
319
- checkOnForeground: false,
320
- throttleMs: 100,
321
- checkCooldownMs: 0,
317
+ checkOnForeground: true,
318
+ checkIntervalMs: 100_000,
322
319
  });
323
320
  client.initialize();
324
321
 
325
322
  const foregroundHandler = mockAppStateListeners[0];
326
323
 
327
- // First foreground
324
+ // First foreground - should trigger check
328
325
  await foregroundHandler("active");
329
326
  await new Promise((r) => setTimeout(r, 10));
330
327
 
331
328
  const callsAfterFirst = mockIdentity.getIdentifyCallCount();
332
329
 
333
- // Second foreground immediately (within throttle window)
330
+ // Second foreground immediately (within interval window)
334
331
  await foregroundHandler("active");
335
332
  await new Promise((r) => setTimeout(r, 10));
336
333
 
337
334
  const callsAfterSecond = mockIdentity.getIdentifyCallCount();
338
335
 
339
- // Only first call should have triggered identify (throttle blocks second)
336
+ // Only first call should have triggered identify (interval blocks second)
340
337
  expect(callsAfterFirst).toBe(1);
341
338
  expect(callsAfterSecond).toBe(1);
342
339
 
343
340
  client.shutdown();
344
341
  });
345
342
 
346
- test("checkOnForeground: true always checks on foreground", async () => {
343
+ test("checkOnForeground: true respects interval", async () => {
347
344
  const mockIdentity = createMockIdentityClient();
348
345
  const mockLogging = createMockLoggingClient();
349
346
  const mockStorage = createMockStorageClient();
350
347
 
351
348
  const client = new ForceUpdateClient(mockLogging as never, mockStorage as never, mockIdentity as never, {
352
349
  checkOnForeground: true,
353
- throttleMs: 100_000,
350
+ checkIntervalMs: 100_000,
354
351
  });
355
352
  client.initialize();
356
353
 
@@ -362,28 +359,77 @@ describe("ForceUpdateClient", () => {
362
359
 
363
360
  const callsAfterFirst = mockIdentity.getIdentifyCallCount();
364
361
 
365
- // Second foreground immediately (within throttle window)
362
+ // Second foreground immediately (within interval window)
366
363
  await foregroundHandler("active");
367
364
  await new Promise((r) => setTimeout(r, 10));
368
365
 
369
366
  const callsAfterSecond = mockIdentity.getIdentifyCallCount();
370
367
 
371
- // Both should trigger identify calls with checkOnForeground: true
368
+ // Only first should trigger - interval blocks second even with checkOnForeground: true
372
369
  expect(callsAfterFirst).toBe(1);
373
- expect(callsAfterSecond).toBe(2);
370
+ expect(callsAfterSecond).toBe(1);
374
371
 
375
372
  client.shutdown();
376
373
  });
377
374
 
378
- test("cooldown prevents checks too soon after successful check", async () => {
375
+ test("checkOnForeground: false disables foreground checking entirely", async () => {
379
376
  const mockIdentity = createMockIdentityClient();
380
377
  const mockLogging = createMockLoggingClient();
381
378
  const mockStorage = createMockStorageClient();
382
379
 
383
380
  const client = new ForceUpdateClient(mockLogging as never, mockStorage as never, mockIdentity as never, {
384
381
  checkOnForeground: false,
385
- throttleMs: 0,
386
- checkCooldownMs: 100_000,
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 () => {
398
+ const mockIdentity = createMockIdentityClient();
399
+ const mockLogging = createMockLoggingClient();
400
+ const mockStorage = createMockStorageClient();
401
+
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,
387
433
  });
388
434
  client.initialize();
389
435
 
@@ -395,13 +441,13 @@ describe("ForceUpdateClient", () => {
395
441
 
396
442
  const callsAfterFirst = mockIdentity.getIdentifyCallCount();
397
443
 
398
- // Second foreground immediately (within cooldown window)
444
+ // Second foreground immediately - should be blocked by 30s minimum
399
445
  await foregroundHandler("active");
400
446
  await new Promise((r) => setTimeout(r, 10));
401
447
 
402
448
  const callsAfterSecond = mockIdentity.getIdentifyCallCount();
403
449
 
404
- // Only first call should have triggered identify (cooldown blocks second)
450
+ // Only first call should have triggered (30s minimum enforced)
405
451
  expect(callsAfterFirst).toBe(1);
406
452
  expect(callsAfterSecond).toBe(1);
407
453
 
@@ -69,41 +69,30 @@ export interface VersionStatusChangeEvents {
69
69
 
70
70
  export type ForceUpdateClientOptions = {
71
71
  /**
72
- * Minimum time (ms) between foreground transitions to prevent rapid-fire checks.
73
- * Measured from the last time the app came to foreground.
74
- * Prevents checking when user quickly switches apps back and forth.
75
- * Default: 30000 (30 seconds)
76
- *
77
- * Special values:
78
- * - -1: Disable throttling, check on every foreground (respects checkCooldownMs)
79
- *
80
- * Example: If throttleMs is 30s and user backgrounds then foregrounds the app
81
- * twice within 20s, only the first transition triggers a check.
82
- */
83
- throttleMs?: number;
84
- /**
85
- * Minimum time (ms) since the last successful version check before checking again.
86
- * Measured from when the last check completed successfully (not when it started).
87
- * Prevents unnecessary API calls after we already have fresh version data.
72
+ * Minimum time (ms) between version checks.
88
73
  * Default: 300000 (5 minutes)
89
74
  *
75
+ * Values below 30 seconds are clamped to 30 seconds to prevent excessive API calls.
76
+ *
90
77
  * Special values:
91
- * - 0: Disable cooldown, check on every foreground (respects throttleMs)
92
- * - -1: Disable all automatic version checking
78
+ * - 0: Check on every foreground (no interval)
79
+ * - -1: Disable automatic version checking entirely
93
80
  *
94
- * Example: If checkCooldownMs is 5min and a check completes at 12:00pm,
81
+ * Example: If checkIntervalMs is 5min and a check completes at 12:00pm,
95
82
  * no new checks occur until 12:05pm, even if user foregrounds the app multiple times.
96
83
  */
97
- checkCooldownMs?: number;
98
- /** Always check on foreground, ignoring throttle (default: true) */
84
+ checkIntervalMs?: number;
85
+ /** Check version when app comes to foreground, respecting checkIntervalMs (default: true) */
99
86
  checkOnForeground?: boolean;
100
87
  /** If true, check version even when not identified by using anonymous device identification (default: false) */
101
88
  identifyAnonymousDevice?: boolean;
102
89
  };
103
90
 
91
+ /** Hard minimum interval between checks to prevent excessive API calls */
92
+ const MIN_CHECK_INTERVAL_MS = 30_000; // 30 seconds
93
+
104
94
  const DEFAULT_OPTIONS: Required<ForceUpdateClientOptions> = {
105
- throttleMs: 30_000, // 30 seconds
106
- checkCooldownMs: 300_000, // 5 minutes
95
+ checkIntervalMs: 300_000, // 5 minutes
107
96
  checkOnForeground: true,
108
97
  identifyAnonymousDevice: false,
109
98
  };
@@ -116,7 +105,6 @@ export class ForceUpdateClient {
116
105
  private unsubscribe: (() => void) | null = null;
117
106
  private appStateSubscription: NativeEventSubscription | null = null;
118
107
  private lastCheckTime: number | null = null;
119
- private lastForegroundTime: number | null = null;
120
108
  private initialized = false;
121
109
 
122
110
  private readonly logger: Logger;
@@ -132,7 +120,6 @@ export class ForceUpdateClient {
132
120
  this.logger = logging.createLogger({ name: "ForceUpdateClient" });
133
121
  this.storage = storage.createStorage("version");
134
122
  this.options = { ...DEFAULT_OPTIONS, ...options };
135
- // Don't initialize here - defer to initialize()
136
123
  }
137
124
 
138
125
  initialize(): void {
@@ -142,7 +129,6 @@ export class ForceUpdateClient {
142
129
  }
143
130
  this.initialized = true;
144
131
 
145
- // Load from storage, subscribe to events, and sync with current identity state
146
132
  this.versionStatus = this.getVersionStatusFromStorage();
147
133
  this.logger.debug(`Initialized with version status: ${this.versionStatus.type}`);
148
134
  this.subscribeToIdentity();
@@ -164,25 +150,30 @@ export class ForceUpdateClient {
164
150
  }
165
151
 
166
152
  private getVersionStatusFromStorage(): VersionStatus {
167
- const stored = this.storage.getItem(VERSION_STATUS_STORAGE_KEY);
153
+ try {
154
+ const stored = this.storage.getItem(VERSION_STATUS_STORAGE_KEY);
168
155
 
169
- if (stored == null) {
170
- this.logger.debug("No stored version status, returning initializing");
171
- return InitializingVersionStatusSchema.parse({ type: "initializing" });
172
- }
156
+ if (stored == null) {
157
+ this.logger.debug("No stored version status, returning initializing");
158
+ return InitializingVersionStatusSchema.parse({ type: "initializing" });
159
+ }
173
160
 
174
- const parsed = VersionStatusSchema.parse(JSON.parse(stored));
175
- 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
+ }
176
171
 
177
- // "checking" and "initializing" are transient states - if we restore them, reset to initializing
178
- // This can happen if the app was killed during a version check
179
- if (parsed.type === "checking" || parsed.type === "initializing") {
180
- this.logger.debug(`Found stale '${parsed.type}' state in storage, resetting to initializing`);
181
- this.storage.removeItem(VERSION_STATUS_STORAGE_KEY);
172
+ return parsed;
173
+ } catch (error) {
174
+ this.logger.debugError("Error getting version status from storage", { error });
182
175
  return InitializingVersionStatusSchema.parse({ type: "initializing" });
183
176
  }
184
-
185
- return parsed;
186
177
  }
187
178
 
188
179
  private saveVersionStatusToStorage(status: VersionStatus): void {
@@ -239,30 +230,22 @@ export class ForceUpdateClient {
239
230
  if (nextState === "active") {
240
231
  this.logger.debug("App state changed to active");
241
232
 
242
- // If checkCooldownMs is -1, disable checking entirely
243
- if (this.options.checkCooldownMs === -1) {
244
- 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)");
245
236
  return;
246
237
  }
247
238
 
248
239
  const now = Date.now();
249
240
 
250
- // If throttleMs is -1, disable throttling (always pass)
251
- // Otherwise, check if enough time has passed since last foreground
252
- const throttleOk =
253
- this.options.throttleMs === -1 ||
254
- !this.lastForegroundTime ||
255
- now - this.lastForegroundTime >= this.options.throttleMs;
256
-
257
- // If checkCooldownMs is 0, always allow check (no cooldown)
258
- // Otherwise, check if enough time has passed since last successful check
259
- const cooldownOk =
260
- this.options.checkCooldownMs === 0 ||
261
- !this.lastCheckTime ||
262
- now - this.lastCheckTime >= this.options.checkCooldownMs;
263
-
264
- if (this.options.checkOnForeground || (throttleOk && cooldownOk)) {
265
- this.lastForegroundTime = now;
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);
244
+
245
+ // Check if enough time has passed since last successful check
246
+ const canCheck = effectiveInterval === 0 || !this.lastCheckTime || now - this.lastCheckTime >= effectiveInterval;
247
+
248
+ if (this.options.checkOnForeground && canCheck) {
266
249
  this.checkVersionOnForeground();
267
250
  }
268
251
  }
@@ -25,6 +25,8 @@ 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
32
  clearLogs: () => {
@@ -256,20 +258,26 @@ describe("IdentityClient", () => {
256
258
  expect(client.getIdentifyState().type).toBe("unidentified");
257
259
  });
258
260
 
259
- test("throws error on invalid stored state (corrupt JSON)", () => {
261
+ test("gracefully handles invalid stored state (corrupt JSON)", () => {
260
262
  const mockStorage = createMockStorageClient();
261
263
  mockStorage.getStorage().set(IDENTIFY_STORAGE_KEY, "not-valid-json{{{");
262
264
 
263
265
  const { client } = createTestClient({ storage: mockStorage });
264
- expect(() => loadStateFromStorage(client)).toThrow();
266
+ loadStateFromStorage(client);
267
+
268
+ // Should gracefully fall back to unidentified state
269
+ expect(client.getIdentifyState().type).toBe("unidentified");
265
270
  });
266
271
 
267
- test("throws error on invalid stored state (schema mismatch)", () => {
272
+ test("gracefully handles invalid stored state (schema mismatch)", () => {
268
273
  const mockStorage = createMockStorageClient();
269
274
  mockStorage.getStorage().set(IDENTIFY_STORAGE_KEY, JSON.stringify({ type: "invalid_type" }));
270
275
 
271
276
  const { client } = createTestClient({ storage: mockStorage });
272
- expect(() => loadStateFromStorage(client)).toThrow();
277
+ loadStateFromStorage(client);
278
+
279
+ // Should gracefully fall back to unidentified state
280
+ expect(client.getIdentifyState().type).toBe("unidentified");
273
281
  });
274
282
 
275
283
  test("creates logger with correct name", () => {
@@ -627,10 +635,10 @@ describe("IdentityClient", () => {
627
635
 
628
636
  // Should have debug logs about state transitions
629
637
  // When already identified, identify() will transition: identified -> identifying -> identified
630
- const debugLogs = mockLogging.getLogs().filter((l) => l.level === "debug");
638
+ const debugLogs = mockLogging.getLogs().filter((l) => l.level === "debug" || l.level === "debugInfo");
631
639
  expect(debugLogs.length).toBeGreaterThan(0);
632
640
  // Check that state transitions are logged
633
- expect(debugLogs.some((l) => l.message.includes("Identify state:"))).toBe(true);
641
+ expect(debugLogs.some((l) => l.message.includes("Identify state"))).toBe(true);
634
642
  });
635
643
  });
636
644