@teardown/react-native 2.0.11 → 2.0.14

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.11",
3
+ "version": "2.0.14",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -50,9 +50,9 @@
50
50
  "nuke": "cd ../../ && bun run nuke"
51
51
  },
52
52
  "dependencies": {
53
- "@teardown/ingest-api": "0.1.46",
54
- "@teardown/schemas": "0.1.46",
55
- "@teardown/types": "0.1.46",
53
+ "@teardown/ingest-api": "0.1.49",
54
+ "@teardown/schemas": "0.1.49",
55
+ "@teardown/types": "0.1.49",
56
56
  "eventemitter3": "^5.0.1",
57
57
  "react-native-get-random-values": "^2.0.0",
58
58
  "uuid": "^13.0.0",
@@ -63,25 +63,31 @@
63
63
  "@teardown/tsconfig": "1.0.0",
64
64
  "@types/bun": "latest",
65
65
  "@types/react": "*",
66
- "@types/uuid": "^11.0.0",
67
- "expo-updates": "^29.0.12"
66
+ "@types/uuid": "^11.0.0"
68
67
  },
69
68
  "peerDependencies": {
70
- "@react-native-async-storage/async-storage": "^1.21 || ^2.0",
71
- "expo-application": "*",
72
- "expo-device": "*",
73
- "expo-notifications": "*",
74
- "expo-secure-store": "*",
75
- "react": "*",
76
- "react-native": "*",
77
- "react-native-device-info": "^15",
78
- "react-native-mmkv": "^4.0.0",
79
- "typescript": "*"
69
+ "@react-native-async-storage/async-storage": "~2.0",
70
+ "expo-application": "~7",
71
+ "expo-device": "~8",
72
+ "expo-notifications": "~0.32",
73
+ "expo-secure-store": "~15",
74
+ "expo-updates": "~29",
75
+ "react": "~19",
76
+ "react-native": "~0.81",
77
+ "react-native-device-info": "~15",
78
+ "react-native-mmkv": "^4",
79
+ "typescript": "~5"
80
80
  },
81
81
  "peerDependenciesMeta": {
82
82
  "@react-native-async-storage/async-storage": {
83
83
  "optional": true
84
84
  },
85
+ "react": {
86
+ "optional": true
87
+ },
88
+ "react-native": {
89
+ "optional": true
90
+ },
85
91
  "react-native-device-info": {
86
92
  "optional": true
87
93
  },
@@ -5,7 +5,7 @@ import type { StorageClient } from "../storage";
5
5
 
6
6
  export type { Eden, IngestApi };
7
7
 
8
- const TEARDOWN_INGEST_URL = "http://localhost:4880";// "https://ingest.teardown.dev";
8
+ const TEARDOWN_INGEST_URL = "https://ingest.teardown.dev";
9
9
  const TEARDOWN_API_KEY_HEADER = "td-api-key";
10
10
  const TEARDOWN_ORG_ID_HEADER = "td-org-id";
11
11
  const TEARDOWN_PROJECT_ID_HEADER = "td-project-id";
@@ -26,8 +26,10 @@ export type ApiClientOptions = {
26
26
  project_id: string;
27
27
  /**
28
28
  * The slug of the environment.
29
+ *
30
+ * @default "production"
29
31
  */
30
- environment_slug: string;
32
+ environment_slug?: string | null;
31
33
  /**
32
34
  * A function that will be called before each request.
33
35
  * @param endpoint The endpoint being requested.
@@ -36,7 +38,6 @@ export type ApiClientOptions = {
36
38
  */
37
39
  onRequest?: (endpoint: IngestApi.Endpoints, options: IngestApi.RequestOptions) => Promise<IngestApi.RequestOptions>;
38
40
 
39
-
40
41
  /**
41
42
  * The URL of the ingest API.
42
43
  * @default https://ingest.teardown.dev
@@ -54,10 +55,10 @@ export class ApiClient {
54
55
  ) {
55
56
  this.client = IngestApi.client(options.ingestUrl ?? TEARDOWN_INGEST_URL, {
56
57
  headers: {
57
- [TEARDOWN_API_KEY_HEADER]: `Bearer ${this.options.api_key}`,
58
- [TEARDOWN_ORG_ID_HEADER]: this.options.org_id,
59
- [TEARDOWN_PROJECT_ID_HEADER]: this.options.project_id,
60
- [TEARDOWN_ENVIRONMENT_SLUG_HEADER]: this.options.environment_slug,
58
+ [TEARDOWN_API_KEY_HEADER]: `Bearer ${this.apiKey}`,
59
+ [TEARDOWN_ORG_ID_HEADER]: this.orgId,
60
+ [TEARDOWN_PROJECT_ID_HEADER]: this.projectId,
61
+ [TEARDOWN_ENVIRONMENT_SLUG_HEADER]: this.environmentSlug,
61
62
  },
62
63
  });
63
64
  }
@@ -75,6 +76,6 @@ export class ApiClient {
75
76
  }
76
77
 
77
78
  get environmentSlug(): string {
78
- return this.options.environment_slug;
79
+ return this.options.environment_slug ?? "production";
79
80
  }
80
81
  }
@@ -491,7 +491,7 @@ describe("IdentityClient", () => {
491
491
  await client.identify(persona);
492
492
 
493
493
  const lastCall = mockApi.getLastCall();
494
- expect((lastCall.config.body as { persona?: Persona }).persona).toEqual(persona);
494
+ expect((lastCall.config.body as { user?: Persona }).user).toEqual(persona);
495
495
  });
496
496
 
497
497
  test("passes undefined persona when not provided", async () => {
@@ -500,7 +500,7 @@ describe("IdentityClient", () => {
500
500
  await client.identify();
501
501
 
502
502
  const lastCall = mockApi.getLastCall();
503
- expect((lastCall.config.body as { persona?: Persona }).persona).toBeUndefined();
503
+ expect((lastCall.config.body as { user?: Persona }).user).toBeUndefined();
504
504
  });
505
505
 
506
506
  test("passes partial persona data", async () => {
@@ -511,7 +511,7 @@ describe("IdentityClient", () => {
511
511
  await client.identify(persona);
512
512
 
513
513
  const lastCall = mockApi.getLastCall();
514
- expect((lastCall.config.body as { persona?: Persona }).persona).toEqual({ email: "only-email@test.com" });
514
+ expect((lastCall.config.body as { user?: Persona }).user).toEqual({ email: "only-email@test.com" });
515
515
  });
516
516
 
517
517
  test("calls correct API endpoint", async () => {
@@ -1086,7 +1086,7 @@ describe("IdentityClient", () => {
1086
1086
  await client.identify({});
1087
1087
 
1088
1088
  const lastCall = mockApi.getLastCall();
1089
- expect(lastCall.config.body.persona).toEqual({});
1089
+ expect((lastCall.config.body as { user?: Persona }).user).toEqual({});
1090
1090
  });
1091
1091
 
1092
1092
  test("handles very long session tokens", async () => {
@@ -21,9 +21,9 @@ export type IdentityUser = {
21
21
  token: string;
22
22
  version_info: {
23
23
  status: IdentifyVersionStatusEnum;
24
- update: null
25
- }
26
- }
24
+ update: null;
25
+ };
26
+ };
27
27
 
28
28
  export const UnidentifiedSessionStateSchema = z.object({
29
29
  type: z.literal("unidentified"),
@@ -32,7 +32,6 @@ export const IdentifyingSessionStateSchema = z.object({
32
32
  type: z.literal("identifying"),
33
33
  });
34
34
 
35
-
36
35
  export const UpdateVersionStatusBodySchema = z.object({
37
36
  version: z.string(),
38
37
  });
@@ -59,7 +58,11 @@ export const IdentifiedSessionStateSchema = z.object({
59
58
  }),
60
59
  });
61
60
 
62
- export const IdentifyStateSchema = z.discriminatedUnion("type", [UnidentifiedSessionStateSchema, IdentifyingSessionStateSchema, IdentifiedSessionStateSchema]);
61
+ export const IdentifyStateSchema = z.discriminatedUnion("type", [
62
+ UnidentifiedSessionStateSchema,
63
+ IdentifyingSessionStateSchema,
64
+ IdentifiedSessionStateSchema,
65
+ ]);
63
66
  export type IdentifyState = z.infer<typeof IdentifyStateSchema>;
64
67
 
65
68
  export type UnidentifiedSessionState = z.infer<typeof UnidentifiedSessionStateSchema>;
@@ -70,7 +73,6 @@ export type IdentifyStateChangeEvents = {
70
73
  IDENTIFY_STATE_CHANGED: (state: IdentifyState) => void;
71
74
  };
72
75
 
73
-
74
76
  export const IDENTIFY_STORAGE_KEY = "IDENTIFY_STATE";
75
77
 
76
78
  export class IdentityClient {
@@ -141,7 +143,6 @@ export class IdentityClient {
141
143
  }
142
144
 
143
145
  private setIdentifyState(newState: IdentifyState): void {
144
-
145
146
  if (this.identifyState.type === newState.type) {
146
147
  this.logger.debug(`Identify state already set: ${this.identifyState.type}`);
147
148
  return;
@@ -216,78 +217,81 @@ export class IdentityClient {
216
217
  const previousState = this.identifyState;
217
218
  this.setIdentifyState({ type: "identifying" });
218
219
 
219
- return this.tryCatch(async () => {
220
- this.logger.debug("Getting device ID...");
221
- const deviceId = await this.device.getDeviceId();
222
- const deviceInfo = await this.device.getDeviceInfo();
223
- this.logger.debug("Calling identify API...");
224
- const response = await this.api.client("/v1/identify", {
225
- method: "POST",
226
- headers: {
227
- "td-api-key": this.api.apiKey,
228
- "td-org-id": this.api.orgId,
229
- "td-project-id": this.api.projectId,
230
- "td-environment-slug": "production",
231
- "td-device-id": deviceId,
232
- },
233
- body: {
234
- user,
235
- device: {
236
- timestamp: deviceInfo.timestamp,
237
- os: deviceInfo.os,
238
- application: deviceInfo.application,
239
- hardware: deviceInfo.hardware,
240
- update: null,
220
+ return this.tryCatch(
221
+ async () => {
222
+ this.logger.debug("Getting device ID...");
223
+ const deviceId = await this.device.getDeviceId();
224
+ const deviceInfo = await this.device.getDeviceInfo();
225
+ this.logger.debug("Calling identify API...");
226
+ const response = await this.api.client("/v1/identify", {
227
+ method: "POST",
228
+ headers: {
229
+ "td-api-key": this.api.apiKey,
230
+ "td-org-id": this.api.orgId,
231
+ "td-project-id": this.api.projectId,
232
+ "td-environment-slug": "production",
233
+ "td-device-id": deviceId,
241
234
  },
242
- },
243
- });
244
-
245
- this.logger.debug(`Identify API response received`);
246
- if (response.error != null) {
247
- this.logger.warn("Identify API error", response.error.status, response.error.value);
248
- this.setIdentifyState(previousState);
249
-
250
- if (response.error.status === 422) {
251
- this.logger.warn("422 Error identifying user", response.error.value);
235
+ body: {
236
+ user,
237
+ device: {
238
+ timestamp: deviceInfo.timestamp,
239
+ os: deviceInfo.os,
240
+ application: deviceInfo.application,
241
+ hardware: deviceInfo.hardware,
242
+ update: null,
243
+ },
244
+ },
245
+ });
246
+
247
+ this.logger.debug(`Identify API response received`);
248
+ if (response.error != null) {
249
+ this.logger.warn("Identify API error", response.error.status, response.error.value);
250
+ this.setIdentifyState(previousState);
251
+
252
+ if (response.error.status === 422) {
253
+ this.logger.warn("422 Error identifying user", response.error.value);
254
+ return {
255
+ success: false,
256
+ error: response.error.value.message ?? "Unknown error",
257
+ };
258
+ }
259
+
260
+ const value = response.error.value;
252
261
  return {
253
262
  success: false,
254
- error: response.error.value.message ?? "Unknown error",
263
+ error: value?.error?.message ?? "Unknown error",
255
264
  };
256
265
  }
257
266
 
258
- const value = response.error.value;
259
- return {
260
- success: false,
261
- error: value?.error?.message ?? "Unknown error",
262
- };
263
- }
264
-
265
- this.setIdentifyState({
266
- type: "identified",
267
- session: response.data.data,
268
- version_info: {
269
- status: response.data.data.version_info.status,
270
- update: null,
271
- },
272
- });
273
-
274
- return {
275
- success: true,
276
- data: {
277
- ...response.data.data,
267
+ this.setIdentifyState({
268
+ type: "identified",
269
+ session: response.data.data,
278
270
  version_info: {
279
271
  status: response.data.data.version_info.status,
280
272
  update: null,
281
273
  },
282
- },
283
- };
284
- }, (error) => {
285
- this.logger.error("Error identifying user", error);
286
- this.setIdentifyState(previousState);
287
- return {
288
- success: false,
289
- error: error.message,
290
- };
291
- });
274
+ });
275
+
276
+ return {
277
+ success: true,
278
+ data: {
279
+ ...response.data.data,
280
+ version_info: {
281
+ status: response.data.data.version_info.status,
282
+ update: null,
283
+ },
284
+ },
285
+ };
286
+ },
287
+ (error) => {
288
+ this.logger.error("Error identifying user", error);
289
+ this.setIdentifyState(previousState);
290
+ return {
291
+ success: false,
292
+ error: error.message,
293
+ };
294
+ }
295
+ );
292
296
  }
293
297
  }
@@ -1,10 +1,9 @@
1
1
  import * as MMKV from "react-native-mmkv";
2
2
  import { StorageAdapter, type SupportedStorage } from "./storage.adpater-interface";
3
3
 
4
-
5
4
  export class MMKVStorageAdapter extends StorageAdapter {
6
5
  createStorage(storageKey: string): SupportedStorage {
7
- const storage = MMKV.createMMKV({ id: storageKey });
6
+ const storage = MMKV.createMMKV({ id: storageKey, encryptionKey: storageKey });
8
7
  return {
9
8
  preload: () => {
10
9
  storage.getAllKeys();
@@ -16,5 +15,4 @@ export class MMKVStorageAdapter extends StorageAdapter {
16
15
  keys: () => storage.getAllKeys(),
17
16
  };
18
17
  }
19
-
20
- }
18
+ }
@@ -0,0 +1,579 @@
1
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
2
+ import type { SupportedStorage } from "./storage.adpater-interface";
3
+
4
+ /**
5
+ * Mock MMKV storage instance
6
+ */
7
+ function createMockMMKVInstance() {
8
+ const store = new Map<string, string>();
9
+ return {
10
+ getString: mock((key: string) => store.get(key)),
11
+ set: mock((key: string, value: string) => {
12
+ store.set(key, value);
13
+ }),
14
+ remove: mock((key: string) => {
15
+ store.delete(key);
16
+ }),
17
+ clearAll: mock(() => {
18
+ store.clear();
19
+ }),
20
+ getAllKeys: mock(() => Array.from(store.keys())),
21
+ _store: store, // expose for test assertions
22
+ };
23
+ }
24
+
25
+ /**
26
+ * Mock AsyncStorage
27
+ */
28
+ function createMockAsyncStorage() {
29
+ const store = new Map<string, string>();
30
+ return {
31
+ getAllKeys: mock(async () => Array.from(store.keys())),
32
+ multiGet: mock(async (keys: string[]) =>
33
+ keys.map((k) => [k, store.get(k) ?? null] as [string, string | null])
34
+ ),
35
+ setItem: mock(async (key: string, value: string) => {
36
+ store.set(key, value);
37
+ }),
38
+ removeItem: mock(async (key: string) => {
39
+ store.delete(key);
40
+ }),
41
+ multiRemove: mock(async (keys: string[]) => {
42
+ for (const key of keys) {
43
+ store.delete(key);
44
+ }
45
+ }),
46
+ _store: store,
47
+ _reset: () => {
48
+ store.clear();
49
+ },
50
+ };
51
+ }
52
+
53
+ // Create mock instances
54
+ const mockMMKVInstance = createMockMMKVInstance();
55
+ const mockAsyncStorage = createMockAsyncStorage();
56
+
57
+ // Mock the react-native-mmkv module
58
+ mock.module("react-native-mmkv", () => ({
59
+ createMMKV: mock(() => mockMMKVInstance),
60
+ }));
61
+
62
+ // Mock the @react-native-async-storage/async-storage module
63
+ mock.module("@react-native-async-storage/async-storage", () => ({
64
+ default: mockAsyncStorage,
65
+ }));
66
+
67
+ // Import adapters AFTER mocking
68
+ const { MMKVStorageAdapter } = await import("./mmkv.adapter");
69
+ const { AsyncStorageAdapter } = await import("./async-storage.adapter");
70
+
71
+ describe("MMKVStorageAdapter", () => {
72
+ let adapter: InstanceType<typeof MMKVStorageAdapter>;
73
+ let storage: SupportedStorage;
74
+
75
+ beforeEach(() => {
76
+ mockMMKVInstance._store.clear();
77
+ mockMMKVInstance.getString.mockClear();
78
+ mockMMKVInstance.set.mockClear();
79
+ mockMMKVInstance.remove.mockClear();
80
+ mockMMKVInstance.clearAll.mockClear();
81
+ mockMMKVInstance.getAllKeys.mockClear();
82
+
83
+ adapter = new MMKVStorageAdapter();
84
+ storage = adapter.createStorage("test-store");
85
+ });
86
+
87
+ describe("createStorage", () => {
88
+ test("creates storage with correct storageKey", () => {
89
+ const { createMMKV } = require("react-native-mmkv");
90
+ adapter.createStorage("my-app-store");
91
+ expect(createMMKV).toHaveBeenCalledWith({
92
+ id: "my-app-store",
93
+ encryptionKey: "my-app-store",
94
+ });
95
+ });
96
+ });
97
+
98
+ describe("preload", () => {
99
+ test("calls getAllKeys to warm up storage", () => {
100
+ storage.preload();
101
+ expect(mockMMKVInstance.getAllKeys).toHaveBeenCalled();
102
+ });
103
+
104
+ test("is synchronous (returns void)", () => {
105
+ const result = storage.preload();
106
+ expect(result).toBeUndefined();
107
+ });
108
+ });
109
+
110
+ describe("getItem", () => {
111
+ test("returns null for non-existent key", () => {
112
+ expect(storage.getItem("missing")).toBeNull();
113
+ });
114
+
115
+ test("returns stored value", () => {
116
+ mockMMKVInstance._store.set("existing", "value");
117
+ expect(storage.getItem("existing")).toBe("value");
118
+ });
119
+
120
+ test("converts undefined to null", () => {
121
+ // MMKV returns undefined for missing keys
122
+ expect(storage.getItem("undefined-key")).toBeNull();
123
+ });
124
+
125
+ test("handles empty string value", () => {
126
+ mockMMKVInstance._store.set("empty", "");
127
+ expect(storage.getItem("empty")).toBe("");
128
+ });
129
+
130
+ test("handles JSON string value", () => {
131
+ const json = JSON.stringify({ nested: { data: [1, 2, 3] } });
132
+ mockMMKVInstance._store.set("json", json);
133
+ expect(storage.getItem("json")).toBe(json);
134
+ });
135
+
136
+ test("handles unicode values", () => {
137
+ mockMMKVInstance._store.set("unicode", "日本語テスト🎉");
138
+ expect(storage.getItem("unicode")).toBe("日本語テスト🎉");
139
+ });
140
+ });
141
+
142
+ describe("setItem", () => {
143
+ test("stores value", () => {
144
+ storage.setItem("key", "value");
145
+ expect(mockMMKVInstance.set).toHaveBeenCalledWith("key", "value");
146
+ });
147
+
148
+ test("overwrites existing value", () => {
149
+ storage.setItem("key", "original");
150
+ storage.setItem("key", "updated");
151
+ expect(mockMMKVInstance._store.get("key")).toBe("updated");
152
+ });
153
+
154
+ test("handles empty string key", () => {
155
+ storage.setItem("", "empty-key-value");
156
+ expect(mockMMKVInstance.set).toHaveBeenCalledWith("", "empty-key-value");
157
+ });
158
+
159
+ test("handles empty string value", () => {
160
+ storage.setItem("key", "");
161
+ expect(mockMMKVInstance.set).toHaveBeenCalledWith("key", "");
162
+ });
163
+
164
+ test("handles large values", () => {
165
+ const largeValue = "x".repeat(10000);
166
+ storage.setItem("large", largeValue);
167
+ expect(mockMMKVInstance.set).toHaveBeenCalledWith("large", largeValue);
168
+ });
169
+ });
170
+
171
+ describe("removeItem", () => {
172
+ test("removes existing item", () => {
173
+ mockMMKVInstance._store.set("key", "value");
174
+ storage.removeItem("key");
175
+ expect(mockMMKVInstance.remove).toHaveBeenCalledWith("key");
176
+ });
177
+
178
+ test("handles removing non-existent key", () => {
179
+ storage.removeItem("non-existent");
180
+ expect(mockMMKVInstance.remove).toHaveBeenCalledWith("non-existent");
181
+ });
182
+ });
183
+
184
+ describe("clear", () => {
185
+ test("clears all items", () => {
186
+ mockMMKVInstance._store.set("key1", "value1");
187
+ mockMMKVInstance._store.set("key2", "value2");
188
+ storage.clear();
189
+ expect(mockMMKVInstance.clearAll).toHaveBeenCalled();
190
+ });
191
+
192
+ test("handles clearing empty storage", () => {
193
+ storage.clear();
194
+ expect(mockMMKVInstance.clearAll).toHaveBeenCalled();
195
+ });
196
+ });
197
+
198
+ describe("keys", () => {
199
+ test("returns empty array for empty storage", () => {
200
+ expect(storage.keys()).toEqual([]);
201
+ });
202
+
203
+ test("returns all keys", () => {
204
+ mockMMKVInstance._store.set("key1", "value1");
205
+ mockMMKVInstance._store.set("key2", "value2");
206
+ const keys = storage.keys();
207
+ expect(keys).toContain("key1");
208
+ expect(keys).toContain("key2");
209
+ });
210
+ });
211
+ });
212
+
213
+ describe("AsyncStorageAdapter", () => {
214
+ let adapter: InstanceType<typeof AsyncStorageAdapter>;
215
+ let storage: SupportedStorage;
216
+
217
+ beforeEach(() => {
218
+ mockAsyncStorage._reset();
219
+ mockAsyncStorage.getAllKeys.mockClear();
220
+ mockAsyncStorage.multiGet.mockClear();
221
+ mockAsyncStorage.setItem.mockClear();
222
+ mockAsyncStorage.removeItem.mockClear();
223
+ mockAsyncStorage.multiRemove.mockClear();
224
+
225
+ adapter = new AsyncStorageAdapter();
226
+ storage = adapter.createStorage("test-store");
227
+ });
228
+
229
+ describe("preload/hydration", () => {
230
+ test("hydrates cache from AsyncStorage", async () => {
231
+ mockAsyncStorage._store.set("test-store:key1", "value1");
232
+ mockAsyncStorage._store.set("test-store:key2", "value2");
233
+ mockAsyncStorage._store.set("other-store:key3", "value3");
234
+
235
+ await storage.preload();
236
+
237
+ expect(storage.getItem("key1")).toBe("value1");
238
+ expect(storage.getItem("key2")).toBe("value2");
239
+ expect(storage.getItem("key3")).toBeNull(); // different prefix
240
+ });
241
+
242
+ test("only hydrates once", async () => {
243
+ await storage.preload();
244
+ await storage.preload();
245
+ await storage.preload();
246
+
247
+ expect(mockAsyncStorage.getAllKeys).toHaveBeenCalledTimes(1);
248
+ });
249
+
250
+ test("filters keys by storageKey prefix", async () => {
251
+ mockAsyncStorage._store.set("test-store:mykey", "myvalue");
252
+ mockAsyncStorage._store.set("another-store:otherkey", "othervalue");
253
+
254
+ await storage.preload();
255
+
256
+ expect(mockAsyncStorage.multiGet).toHaveBeenCalledWith(["test-store:mykey"]);
257
+ });
258
+
259
+ test("handles AsyncStorage error gracefully", async () => {
260
+ mockAsyncStorage.getAllKeys.mockImplementationOnce(async () => {
261
+ throw new Error("Storage error");
262
+ });
263
+
264
+ // Should not throw
265
+ await storage.preload();
266
+
267
+ // Cache remains empty
268
+ expect(storage.keys()).toEqual([]);
269
+ });
270
+
271
+ test("sets hydrated flag even on error", async () => {
272
+ mockAsyncStorage.getAllKeys.mockImplementationOnce(async () => {
273
+ throw new Error("Storage error");
274
+ });
275
+
276
+ await storage.preload();
277
+ // Second call should not hit AsyncStorage
278
+ await storage.preload();
279
+
280
+ expect(mockAsyncStorage.getAllKeys).toHaveBeenCalledTimes(1);
281
+ });
282
+
283
+ test("handles null values from multiGet", async () => {
284
+ mockAsyncStorage._store.set("test-store:key1", "value1");
285
+ mockAsyncStorage.multiGet.mockImplementationOnce(async () => [
286
+ ["test-store:key1", "value1"],
287
+ ["test-store:key2", null], // null value
288
+ ]);
289
+
290
+ await storage.preload();
291
+
292
+ expect(storage.getItem("key1")).toBe("value1");
293
+ expect(storage.getItem("key2")).toBeNull();
294
+ });
295
+ });
296
+
297
+ describe("getItem", () => {
298
+ test("returns null before preload", () => {
299
+ mockAsyncStorage._store.set("test-store:key", "value");
300
+ expect(storage.getItem("key")).toBeNull();
301
+ });
302
+
303
+ test("returns cached value after preload", async () => {
304
+ mockAsyncStorage._store.set("test-store:key", "value");
305
+ await storage.preload();
306
+ expect(storage.getItem("key")).toBe("value");
307
+ });
308
+
309
+ test("returns value set before preload", () => {
310
+ storage.setItem("key", "value");
311
+ expect(storage.getItem("key")).toBe("value");
312
+ });
313
+
314
+ test("returns null for non-existent key", async () => {
315
+ await storage.preload();
316
+ expect(storage.getItem("missing")).toBeNull();
317
+ });
318
+ });
319
+
320
+ describe("setItem", () => {
321
+ test("stores value in cache immediately", () => {
322
+ storage.setItem("key", "value");
323
+ expect(storage.getItem("key")).toBe("value");
324
+ });
325
+
326
+ test("persists to AsyncStorage with prefixed key", async () => {
327
+ storage.setItem("key", "value");
328
+ // Wait for async operation
329
+ await new Promise((resolve) => setTimeout(resolve, 10));
330
+ expect(mockAsyncStorage.setItem).toHaveBeenCalledWith("test-store:key", "value");
331
+ });
332
+
333
+ test("overwrites existing value", async () => {
334
+ storage.setItem("key", "original");
335
+ storage.setItem("key", "updated");
336
+
337
+ expect(storage.getItem("key")).toBe("updated");
338
+ await new Promise((resolve) => setTimeout(resolve, 10));
339
+ expect(mockAsyncStorage.setItem).toHaveBeenLastCalledWith("test-store:key", "updated");
340
+ });
341
+
342
+ test("handles AsyncStorage error gracefully", async () => {
343
+ mockAsyncStorage.setItem.mockImplementationOnce(async () => {
344
+ throw new Error("Write error");
345
+ });
346
+
347
+ // Should not throw
348
+ storage.setItem("key", "value");
349
+
350
+ // Value should still be in cache
351
+ expect(storage.getItem("key")).toBe("value");
352
+ });
353
+ });
354
+
355
+ describe("removeItem", () => {
356
+ test("removes from cache immediately", async () => {
357
+ await storage.preload();
358
+ storage.setItem("key", "value");
359
+ storage.removeItem("key");
360
+ expect(storage.getItem("key")).toBeNull();
361
+ });
362
+
363
+ test("removes from AsyncStorage with prefixed key", async () => {
364
+ storage.setItem("key", "value");
365
+ storage.removeItem("key");
366
+ await new Promise((resolve) => setTimeout(resolve, 10));
367
+ expect(mockAsyncStorage.removeItem).toHaveBeenCalledWith("test-store:key");
368
+ });
369
+
370
+ test("handles removing non-existent key", async () => {
371
+ storage.removeItem("non-existent");
372
+ await new Promise((resolve) => setTimeout(resolve, 10));
373
+ expect(mockAsyncStorage.removeItem).toHaveBeenCalledWith("test-store:non-existent");
374
+ });
375
+
376
+ test("handles AsyncStorage error gracefully", async () => {
377
+ mockAsyncStorage.removeItem.mockImplementationOnce(async () => {
378
+ throw new Error("Remove error");
379
+ });
380
+
381
+ storage.setItem("key", "value");
382
+ // Should not throw
383
+ storage.removeItem("key");
384
+
385
+ // Value should be removed from cache
386
+ expect(storage.getItem("key")).toBeNull();
387
+ });
388
+ });
389
+
390
+ describe("clear", () => {
391
+ test("clears cache immediately", async () => {
392
+ storage.setItem("key1", "value1");
393
+ storage.setItem("key2", "value2");
394
+ storage.clear();
395
+
396
+ expect(storage.keys()).toEqual([]);
397
+ expect(storage.getItem("key1")).toBeNull();
398
+ expect(storage.getItem("key2")).toBeNull();
399
+ });
400
+
401
+ test("calls multiRemove with prefixed keys", async () => {
402
+ storage.setItem("key1", "value1");
403
+ storage.setItem("key2", "value2");
404
+ storage.clear();
405
+
406
+ await new Promise((resolve) => setTimeout(resolve, 10));
407
+ expect(mockAsyncStorage.multiRemove).toHaveBeenCalledWith(
408
+ expect.arrayContaining(["test-store:key1", "test-store:key2"])
409
+ );
410
+ });
411
+
412
+ test("handles clearing empty storage", async () => {
413
+ storage.clear();
414
+ await new Promise((resolve) => setTimeout(resolve, 10));
415
+ expect(mockAsyncStorage.multiRemove).toHaveBeenCalledWith([]);
416
+ });
417
+
418
+ test("handles AsyncStorage error gracefully", async () => {
419
+ mockAsyncStorage.multiRemove.mockImplementationOnce(async () => {
420
+ throw new Error("Clear error");
421
+ });
422
+
423
+ storage.setItem("key", "value");
424
+ // Should not throw
425
+ storage.clear();
426
+
427
+ // Cache should still be cleared
428
+ expect(storage.keys()).toEqual([]);
429
+ });
430
+ });
431
+
432
+ describe("keys", () => {
433
+ test("returns empty array for empty cache", () => {
434
+ expect(storage.keys()).toEqual([]);
435
+ });
436
+
437
+ test("returns keys from cache", () => {
438
+ storage.setItem("key1", "value1");
439
+ storage.setItem("key2", "value2");
440
+ const keys = storage.keys();
441
+ expect(keys).toHaveLength(2);
442
+ expect(keys).toContain("key1");
443
+ expect(keys).toContain("key2");
444
+ });
445
+
446
+ test("reflects removals", () => {
447
+ storage.setItem("key1", "value1");
448
+ storage.setItem("key2", "value2");
449
+ storage.removeItem("key1");
450
+ expect(storage.keys()).toEqual(["key2"]);
451
+ });
452
+
453
+ test("returns unprefixed keys", async () => {
454
+ mockAsyncStorage._store.set("test-store:prefixed-key", "value");
455
+ await storage.preload();
456
+ expect(storage.keys()).toContain("prefixed-key");
457
+ });
458
+ });
459
+
460
+ describe("key prefixing isolation", () => {
461
+ test("different storageKeys are isolated", async () => {
462
+ mockAsyncStorage._store.set("store-a:key", "value-a");
463
+ mockAsyncStorage._store.set("store-b:key", "value-b");
464
+
465
+ const storageA = adapter.createStorage("store-a");
466
+ const storageB = adapter.createStorage("store-b");
467
+
468
+ await storageA.preload();
469
+ await storageB.preload();
470
+
471
+ expect(storageA.getItem("key")).toBe("value-a");
472
+ expect(storageB.getItem("key")).toBe("value-b");
473
+ });
474
+
475
+ test("clear only affects own storageKey", async () => {
476
+ const storageA = adapter.createStorage("store-a");
477
+ const storageB = adapter.createStorage("store-b");
478
+
479
+ storageA.setItem("key", "value-a");
480
+ storageB.setItem("key", "value-b");
481
+
482
+ storageA.clear();
483
+
484
+ expect(storageA.getItem("key")).toBeNull();
485
+ expect(storageB.getItem("key")).toBe("value-b");
486
+ });
487
+ });
488
+ });
489
+
490
+ describe("Edge Cases", () => {
491
+ describe("MMKVStorageAdapter edge cases", () => {
492
+ let storage: SupportedStorage;
493
+
494
+ beforeEach(() => {
495
+ mockMMKVInstance._store.clear();
496
+ mockMMKVInstance.set.mockClear();
497
+ const adapter = new MMKVStorageAdapter();
498
+ storage = adapter.createStorage("edge-test");
499
+ });
500
+
501
+ test("handles special characters in keys", () => {
502
+ storage.setItem("key:with/special-chars", "value");
503
+ expect(mockMMKVInstance.set).toHaveBeenCalledWith("key:with/special-chars", "value");
504
+ });
505
+
506
+ test("handles newlines in values", () => {
507
+ storage.setItem("multiline", "line1\nline2\r\nline3");
508
+ expect(mockMMKVInstance._store.get("multiline")).toBe("line1\nline2\r\nline3");
509
+ });
510
+
511
+ test("handles rapid successive operations", () => {
512
+ for (let i = 0; i < 100; i++) {
513
+ storage.setItem(`key-${i}`, `value-${i}`);
514
+ }
515
+ expect(mockMMKVInstance.set).toHaveBeenCalledTimes(100);
516
+ });
517
+ });
518
+
519
+ describe("AsyncStorageAdapter edge cases", () => {
520
+ let storage: SupportedStorage;
521
+
522
+ beforeEach(() => {
523
+ mockAsyncStorage._reset();
524
+ const adapter = new AsyncStorageAdapter();
525
+ storage = adapter.createStorage("edge-test");
526
+ });
527
+
528
+ test("handles special characters in keys (properly prefixed)", async () => {
529
+ storage.setItem("key:with/special-chars", "value");
530
+ await new Promise((resolve) => setTimeout(resolve, 10));
531
+ expect(mockAsyncStorage.setItem).toHaveBeenCalledWith(
532
+ "edge-test:key:with/special-chars",
533
+ "value"
534
+ );
535
+ });
536
+
537
+ test("handles keys that look like prefixed keys", async () => {
538
+ // Key that contains colon similar to prefix format
539
+ storage.setItem("other:nested:key", "value");
540
+ expect(storage.getItem("other:nested:key")).toBe("value");
541
+ });
542
+
543
+ test("setItem works before preload completes", () => {
544
+ // Start preload but don't await
545
+ storage.preload();
546
+ storage.setItem("key", "value");
547
+
548
+ // Should be immediately available in cache
549
+ expect(storage.getItem("key")).toBe("value");
550
+ });
551
+
552
+ test("handles concurrent operations", async () => {
553
+ for (let i = 0; i < 50; i++) {
554
+ storage.setItem(`key-${i}`, `value-${i}`);
555
+ }
556
+
557
+ await new Promise((resolve) => setTimeout(resolve, 50));
558
+
559
+ for (let i = 0; i < 50; i++) {
560
+ expect(storage.getItem(`key-${i}`)).toBe(`value-${i}`);
561
+ }
562
+ });
563
+
564
+ test("cache survives failed preload", async () => {
565
+ // Set value before preload
566
+ storage.setItem("cached", "value");
567
+
568
+ // Force preload to fail
569
+ mockAsyncStorage.getAllKeys.mockImplementationOnce(async () => {
570
+ throw new Error("Network error");
571
+ });
572
+
573
+ await storage.preload();
574
+
575
+ // Cached value should still be there
576
+ expect(storage.getItem("cached")).toBe("value");
577
+ });
578
+ });
579
+ });
@@ -1,7 +1,7 @@
1
1
  import { memo, useEffect, useMemo } from "react";
2
2
 
3
- import type { TeardownCore } from "../teardown.core";
4
3
  import { TeardownContext } from "../contexts/teardown.context";
4
+ import type { TeardownCore } from "../teardown.core";
5
5
 
6
6
  export { useTeardown } from "../contexts/teardown.context";
7
7
 
@@ -20,9 +20,5 @@ export const TeardownProvider = memo((props: TeardownProviderProps) => {
20
20
  };
21
21
  }, [core]);
22
22
 
23
- return (
24
- <TeardownContext.Provider value={context}>
25
- {children}
26
- </TeardownContext.Provider>
27
- );
23
+ return <TeardownContext.Provider value={context}>{children}</TeardownContext.Provider>;
28
24
  });