react-native-nitro-storage 0.3.1 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +199 -10
  2. package/android/CMakeLists.txt +2 -0
  3. package/android/src/main/cpp/AndroidStorageAdapterCpp.cpp +4 -0
  4. package/android/src/main/cpp/AndroidStorageAdapterCpp.hpp +1 -0
  5. package/android/src/main/java/com/nitrostorage/AndroidStorageAdapter.kt +36 -13
  6. package/cpp/bindings/HybridStorage.cpp +55 -9
  7. package/cpp/bindings/HybridStorage.hpp +19 -2
  8. package/cpp/core/NativeStorageAdapter.hpp +1 -0
  9. package/ios/IOSStorageAdapterCpp.hpp +1 -0
  10. package/ios/IOSStorageAdapterCpp.mm +7 -1
  11. package/lib/commonjs/index.js +139 -63
  12. package/lib/commonjs/index.js.map +1 -1
  13. package/lib/commonjs/index.web.js +236 -89
  14. package/lib/commonjs/index.web.js.map +1 -1
  15. package/lib/commonjs/storage-hooks.js +36 -0
  16. package/lib/commonjs/storage-hooks.js.map +1 -0
  17. package/lib/module/index.js +121 -60
  18. package/lib/module/index.js.map +1 -1
  19. package/lib/module/index.web.js +219 -87
  20. package/lib/module/index.web.js.map +1 -1
  21. package/lib/module/storage-hooks.js +30 -0
  22. package/lib/module/storage-hooks.js.map +1 -0
  23. package/lib/typescript/Storage.nitro.d.ts +2 -0
  24. package/lib/typescript/Storage.nitro.d.ts.map +1 -1
  25. package/lib/typescript/index.d.ts +3 -3
  26. package/lib/typescript/index.d.ts.map +1 -1
  27. package/lib/typescript/index.web.d.ts +5 -3
  28. package/lib/typescript/index.web.d.ts.map +1 -1
  29. package/lib/typescript/storage-hooks.d.ts +10 -0
  30. package/lib/typescript/storage-hooks.d.ts.map +1 -0
  31. package/nitrogen/generated/shared/c++/HybridStorageSpec.cpp +2 -0
  32. package/nitrogen/generated/shared/c++/HybridStorageSpec.hpp +2 -0
  33. package/package.json +5 -3
  34. package/src/Storage.nitro.ts +2 -0
  35. package/src/index.ts +143 -83
  36. package/src/index.web.ts +255 -112
  37. package/src/storage-hooks.ts +48 -0
package/src/index.web.ts CHANGED
@@ -1,4 +1,3 @@
1
- import { useRef, useSyncExternalStore } from "react";
2
1
  import { StorageScope, AccessControl, BiometricLevel } from "./Storage.types";
3
2
  import {
4
3
  MIGRATION_VERSION_KEY,
@@ -52,8 +51,18 @@ type RawBatchPathItem = {
52
51
  _secureAccessControl?: AccessControl;
53
52
  };
54
53
 
55
- function asInternal(item: StorageItem<any>): StorageItemInternal<any> {
56
- return item as unknown as StorageItemInternal<any>;
54
+ function asInternal<T>(item: StorageItem<T>): StorageItemInternal<T> {
55
+ return item as StorageItemInternal<T>;
56
+ }
57
+
58
+ function isUpdater<T>(
59
+ valueOrFn: T | ((prev: T) => T),
60
+ ): valueOrFn is (prev: T) => T {
61
+ return typeof valueOrFn === "function";
62
+ }
63
+
64
+ function typedKeys<K extends string, V>(record: Record<K, V>): K[] {
65
+ return Object.keys(record) as K[];
57
66
  }
58
67
  type NonMemoryScope = StorageScope.Disk | StorageScope.Secure;
59
68
  type PendingSecureWrite = { key: string; value: string | undefined };
@@ -88,11 +97,13 @@ export interface Storage {
88
97
  setBatch(keys: string[], values: string[], scope: number): void;
89
98
  getBatch(keys: string[], scope: number): (string | undefined)[];
90
99
  removeBatch(keys: string[], scope: number): void;
100
+ removeByPrefix(prefix: string, scope: number): void;
91
101
  addOnChange(
92
102
  scope: number,
93
103
  callback: (key: string, value: string | undefined) => void,
94
104
  ): () => void;
95
105
  setSecureAccessControl(level: number): void;
106
+ setSecureWritesAsync(enabled: boolean): void;
96
107
  setKeychainAccessGroup(group: string): void;
97
108
  setSecureBiometric(key: string, value: string): void;
98
109
  getSecureBiometric(key: string): string | undefined;
@@ -113,6 +124,11 @@ const scopedRawCache = new Map<NonMemoryScope, Map<string, string | undefined>>(
113
124
  [StorageScope.Secure, new Map()],
114
125
  ],
115
126
  );
127
+ const webScopeKeyIndex = new Map<NonMemoryScope, Set<string>>([
128
+ [StorageScope.Disk, new Set()],
129
+ [StorageScope.Secure, new Set()],
130
+ ]);
131
+ const hydratedWebScopeKeyIndex = new Set<NonMemoryScope>();
116
132
  const pendingSecureWrites = new Map<string, PendingSecureWrite>();
117
133
  let secureFlushScheduled = false;
118
134
  const SECURE_WEB_PREFIX = "__secure_";
@@ -145,6 +161,51 @@ function fromBiometricStorageKey(key: string): string {
145
161
  return key.slice(BIOMETRIC_WEB_PREFIX.length);
146
162
  }
147
163
 
164
+ function getWebScopeKeyIndex(scope: NonMemoryScope): Set<string> {
165
+ return webScopeKeyIndex.get(scope)!;
166
+ }
167
+
168
+ function hydrateWebScopeKeyIndex(scope: NonMemoryScope): void {
169
+ if (hydratedWebScopeKeyIndex.has(scope)) {
170
+ return;
171
+ }
172
+
173
+ const storage = getBrowserStorage(scope);
174
+ const keyIndex = getWebScopeKeyIndex(scope);
175
+ keyIndex.clear();
176
+ if (storage) {
177
+ for (let index = 0; index < storage.length; index += 1) {
178
+ const key = storage.key(index);
179
+ if (!key) {
180
+ continue;
181
+ }
182
+ if (scope === StorageScope.Disk) {
183
+ if (
184
+ !key.startsWith(SECURE_WEB_PREFIX) &&
185
+ !key.startsWith(BIOMETRIC_WEB_PREFIX)
186
+ ) {
187
+ keyIndex.add(key);
188
+ }
189
+ continue;
190
+ }
191
+
192
+ if (key.startsWith(SECURE_WEB_PREFIX)) {
193
+ keyIndex.add(fromSecureStorageKey(key));
194
+ continue;
195
+ }
196
+ if (key.startsWith(BIOMETRIC_WEB_PREFIX)) {
197
+ keyIndex.add(fromBiometricStorageKey(key));
198
+ }
199
+ }
200
+ }
201
+ hydratedWebScopeKeyIndex.add(scope);
202
+ }
203
+
204
+ function ensureWebScopeKeyIndex(scope: NonMemoryScope): Set<string> {
205
+ hydrateWebScopeKeyIndex(scope);
206
+ return getWebScopeKeyIndex(scope);
207
+ }
208
+
148
209
  function getScopedListeners(scope: NonMemoryScope): KeyListenerRegistry {
149
210
  return webScopeListeners.get(scope)!;
150
211
  }
@@ -277,6 +338,7 @@ const WebStorage: Storage = {
277
338
  scope === StorageScope.Secure ? toSecureStorageKey(key) : key;
278
339
  storage.setItem(storageKey, value);
279
340
  if (scope === StorageScope.Disk || scope === StorageScope.Secure) {
341
+ ensureWebScopeKeyIndex(scope).add(key);
280
342
  notifyKeyListeners(getScopedListeners(scope), key);
281
343
  }
282
344
  },
@@ -298,6 +360,7 @@ const WebStorage: Storage = {
298
360
  storage.removeItem(key);
299
361
  }
300
362
  if (scope === StorageScope.Disk || scope === StorageScope.Secure) {
363
+ ensureWebScopeKeyIndex(scope).delete(key);
301
364
  notifyKeyListeners(getScopedListeners(scope), key);
302
365
  }
303
366
  },
@@ -335,6 +398,7 @@ const WebStorage: Storage = {
335
398
  storage.clear();
336
399
  }
337
400
  if (scope === StorageScope.Disk || scope === StorageScope.Secure) {
401
+ ensureWebScopeKeyIndex(scope).clear();
338
402
  notifyAllListeners(getScopedListeners(scope));
339
403
  }
340
404
  },
@@ -345,11 +409,17 @@ const WebStorage: Storage = {
345
409
  }
346
410
 
347
411
  keys.forEach((key, index) => {
412
+ const value = values[index];
413
+ if (value === undefined) {
414
+ return;
415
+ }
348
416
  const storageKey =
349
417
  scope === StorageScope.Secure ? toSecureStorageKey(key) : key;
350
- storage.setItem(storageKey, values[index]);
418
+ storage.setItem(storageKey, value);
351
419
  });
352
420
  if (scope === StorageScope.Disk || scope === StorageScope.Secure) {
421
+ const keyIndex = ensureWebScopeKeyIndex(scope);
422
+ keys.forEach((key) => keyIndex.add(key));
353
423
  const listeners = getScopedListeners(scope);
354
424
  keys.forEach((key) => notifyKeyListeners(listeners, key));
355
425
  }
@@ -363,9 +433,41 @@ const WebStorage: Storage = {
363
433
  });
364
434
  },
365
435
  removeBatch: (keys: string[], scope: number) => {
366
- keys.forEach((key) => {
367
- WebStorage.remove(key, scope);
368
- });
436
+ const storage = getBrowserStorage(scope);
437
+ if (!storage) {
438
+ return;
439
+ }
440
+
441
+ if (scope === StorageScope.Secure) {
442
+ keys.forEach((key) => {
443
+ storage.removeItem(toSecureStorageKey(key));
444
+ storage.removeItem(toBiometricStorageKey(key));
445
+ });
446
+ } else {
447
+ keys.forEach((key) => {
448
+ storage.removeItem(key);
449
+ });
450
+ }
451
+
452
+ if (scope === StorageScope.Disk || scope === StorageScope.Secure) {
453
+ const keyIndex = ensureWebScopeKeyIndex(scope);
454
+ keys.forEach((key) => keyIndex.delete(key));
455
+ const listeners = getScopedListeners(scope);
456
+ keys.forEach((key) => notifyKeyListeners(listeners, key));
457
+ }
458
+ },
459
+ removeByPrefix: (prefix: string, scope: number) => {
460
+ if (scope !== StorageScope.Disk && scope !== StorageScope.Secure) {
461
+ return;
462
+ }
463
+
464
+ const keyIndex = ensureWebScopeKeyIndex(scope);
465
+ const keys = Array.from(keyIndex).filter((key) => key.startsWith(prefix));
466
+ if (keys.length === 0) {
467
+ return;
468
+ }
469
+
470
+ WebStorage.removeBatch(keys, scope);
369
471
  },
370
472
  addOnChange: (
371
473
  _scope: number,
@@ -384,36 +486,19 @@ const WebStorage: Storage = {
384
486
  return storage?.getItem(key) !== null;
385
487
  },
386
488
  getAllKeys: (scope: number) => {
387
- const storage = getBrowserStorage(scope);
388
- if (!storage) return [];
389
- const keys = new Set<string>();
390
- for (let i = 0; i < storage.length; i++) {
391
- const k = storage.key(i);
392
- if (!k) {
393
- continue;
394
- }
395
- if (scope === StorageScope.Secure) {
396
- if (k.startsWith(SECURE_WEB_PREFIX)) {
397
- keys.add(fromSecureStorageKey(k));
398
- } else if (k.startsWith(BIOMETRIC_WEB_PREFIX)) {
399
- keys.add(fromBiometricStorageKey(k));
400
- }
401
- continue;
402
- }
403
- if (
404
- k.startsWith(SECURE_WEB_PREFIX) ||
405
- k.startsWith(BIOMETRIC_WEB_PREFIX)
406
- ) {
407
- continue;
408
- }
409
- keys.add(k);
489
+ if (scope !== StorageScope.Disk && scope !== StorageScope.Secure) {
490
+ return [];
410
491
  }
411
- return Array.from(keys);
492
+ return Array.from(ensureWebScopeKeyIndex(scope));
412
493
  },
413
494
  size: (scope: number) => {
414
- return WebStorage.getAllKeys(scope).length;
495
+ if (scope === StorageScope.Disk || scope === StorageScope.Secure) {
496
+ return ensureWebScopeKeyIndex(scope).size;
497
+ }
498
+ return 0;
415
499
  },
416
500
  setSecureAccessControl: () => {},
501
+ setSecureWritesAsync: (_enabled: boolean) => {},
417
502
  setKeychainAccessGroup: () => {},
418
503
  setSecureBiometric: (key: string, value: string) => {
419
504
  if (
@@ -427,6 +512,7 @@ const WebStorage: Storage = {
427
512
  );
428
513
  }
429
514
  globalThis.localStorage?.setItem(toBiometricStorageKey(key), value);
515
+ ensureWebScopeKeyIndex(StorageScope.Secure).add(key);
430
516
  notifyKeyListeners(getScopedListeners(StorageScope.Secure), key);
431
517
  },
432
518
  getSecureBiometric: (key: string) => {
@@ -435,7 +521,11 @@ const WebStorage: Storage = {
435
521
  );
436
522
  },
437
523
  deleteSecureBiometric: (key: string) => {
438
- globalThis.localStorage?.removeItem(toBiometricStorageKey(key));
524
+ const storage = globalThis.localStorage;
525
+ storage?.removeItem(toBiometricStorageKey(key));
526
+ if (storage?.getItem(toSecureStorageKey(key)) === null) {
527
+ ensureWebScopeKeyIndex(StorageScope.Secure).delete(key);
528
+ }
439
529
  notifyKeyListeners(getScopedListeners(StorageScope.Secure), key);
440
530
  },
441
531
  hasSecureBiometric: (key: string) => {
@@ -456,6 +546,12 @@ const WebStorage: Storage = {
456
546
  }
457
547
  }
458
548
  toRemove.forEach((k) => storage.removeItem(k));
549
+ const keyIndex = ensureWebScopeKeyIndex(StorageScope.Secure);
550
+ keysToNotify.forEach((key) => {
551
+ if (storage.getItem(toSecureStorageKey(key)) === null) {
552
+ keyIndex.delete(key);
553
+ }
554
+ });
459
555
  const listeners = getScopedListeners(StorageScope.Secure);
460
556
  keysToNotify.forEach((key) => notifyKeyListeners(listeners, key));
461
557
  },
@@ -538,9 +634,6 @@ export const storage = {
538
634
 
539
635
  clearScopeRawCache(scope);
540
636
  WebStorage.clear(scope);
541
- if (scope === StorageScope.Secure) {
542
- WebStorage.clearSecureBiometric();
543
- }
544
637
  },
545
638
  clearAll: () => {
546
639
  storage.clear(StorageScope.Memory);
@@ -558,18 +651,12 @@ export const storage = {
558
651
  notifyAllListeners(memoryListeners);
559
652
  return;
560
653
  }
654
+ const keyPrefix = prefixKey(namespace, "");
561
655
  if (scope === StorageScope.Secure) {
562
656
  flushSecureWrites();
563
657
  }
564
- const keys = WebStorage.getAllKeys(scope);
565
- const namespacedKeys = keys.filter((k) => isNamespaced(k, namespace));
566
- if (namespacedKeys.length > 0) {
567
- WebStorage.removeBatch(namespacedKeys, scope);
568
- namespacedKeys.forEach((k) => cacheRawValue(scope, k, undefined));
569
- if (scope === StorageScope.Secure) {
570
- namespacedKeys.forEach((k) => clearPendingSecureWrite(k));
571
- }
572
- }
658
+ clearScopeRawCache(scope);
659
+ WebStorage.removeByPrefix(keyPrefix, scope);
573
660
  },
574
661
  clearBiometric: () => {
575
662
  WebStorage.clearSecureBiometric();
@@ -606,6 +693,10 @@ export const storage = {
606
693
  return WebStorage.size(scope);
607
694
  },
608
695
  setAccessControl: (_level: AccessControl) => {},
696
+ setSecureWritesAsync: (_enabled: boolean) => {},
697
+ flushSecureWrites: () => {
698
+ flushSecureWrites();
699
+ },
609
700
  setKeychainAccessGroup: (_group: string) => {},
610
701
  };
611
702
 
@@ -656,6 +747,14 @@ function canUseRawBatchPath(item: RawBatchPathItem): boolean {
656
747
  );
657
748
  }
658
749
 
750
+ function canUseSecureRawBatchPath(item: RawBatchPathItem): boolean {
751
+ return (
752
+ item._hasExpiration === false &&
753
+ item._hasValidation === false &&
754
+ item._isBiometric !== true
755
+ );
756
+ }
757
+
659
758
  function defaultSerialize<T>(value: T): string {
660
759
  return serializeWithPrimitiveFastPath(value);
661
760
  }
@@ -687,6 +786,7 @@ export function createStorageItem<T = undefined>(
687
786
  config.coalesceSecureWrites === true &&
688
787
  !isBiometric &&
689
788
  secureAccessControl === undefined;
789
+ const defaultValue = config.defaultValue as T;
690
790
  const nonMemoryScope: NonMemoryScope | null =
691
791
  config.scope === StorageScope.Disk
692
792
  ? StorageScope.Disk
@@ -703,11 +803,13 @@ export function createStorageItem<T = undefined>(
703
803
  let lastRaw: unknown = undefined;
704
804
  let lastValue: T | undefined;
705
805
  let hasLastValue = false;
806
+ let lastExpiresAt: number | null | undefined = undefined;
706
807
 
707
808
  const invalidateParsedCache = () => {
708
809
  lastRaw = undefined;
709
810
  lastValue = undefined;
710
811
  hasLastValue = false;
812
+ lastExpiresAt = undefined;
711
813
  };
712
814
 
713
815
  const ensureSubscription = () => {
@@ -744,7 +846,7 @@ export function createStorageItem<T = undefined>(
744
846
  return undefined;
745
847
  }
746
848
  }
747
- return memoryStore.get(storageKey) as T | undefined;
849
+ return memoryStore.get(storageKey);
748
850
  }
749
851
 
750
852
  if (
@@ -839,7 +941,7 @@ export function createStorageItem<T = undefined>(
839
941
  return onValidationError(invalidValue);
840
942
  }
841
943
 
842
- return config.defaultValue as T;
944
+ return defaultValue;
843
945
  };
844
946
 
845
947
  const ensureValidatedValue = (
@@ -852,7 +954,7 @@ export function createStorageItem<T = undefined>(
852
954
 
853
955
  const resolved = resolveInvalidValue(candidate);
854
956
  if (validate && !validate(resolved)) {
855
- return config.defaultValue as T;
957
+ return defaultValue;
856
958
  }
857
959
  if (hadStoredValue) {
858
960
  writeValueWithoutValidation(resolved);
@@ -863,36 +965,61 @@ export function createStorageItem<T = undefined>(
863
965
  const get = (): T => {
864
966
  const raw = readStoredRaw();
865
967
 
866
- const canUseCachedValue = !expiration && !memoryExpiration;
867
- if (canUseCachedValue && raw === lastRaw && hasLastValue) {
868
- return lastValue as T;
968
+ if (!memoryExpiration && raw === lastRaw && hasLastValue) {
969
+ if (!expiration || lastExpiresAt === null) {
970
+ return lastValue as T;
971
+ }
972
+
973
+ if (typeof lastExpiresAt === "number") {
974
+ if (lastExpiresAt > Date.now()) {
975
+ return lastValue as T;
976
+ }
977
+
978
+ removeStoredRaw();
979
+ invalidateParsedCache();
980
+ onExpired?.(storageKey);
981
+ lastValue = ensureValidatedValue(defaultValue, false);
982
+ hasLastValue = true;
983
+ return lastValue;
984
+ }
869
985
  }
870
986
 
871
987
  lastRaw = raw;
872
988
 
873
989
  if (raw === undefined) {
874
- lastValue = ensureValidatedValue(config.defaultValue, false);
990
+ lastExpiresAt = undefined;
991
+ lastValue = ensureValidatedValue(defaultValue, false);
875
992
  hasLastValue = true;
876
993
  return lastValue;
877
994
  }
878
995
 
879
996
  if (isMemory) {
997
+ lastExpiresAt = undefined;
880
998
  lastValue = ensureValidatedValue(raw, true);
881
999
  hasLastValue = true;
882
1000
  return lastValue;
883
1001
  }
884
1002
 
885
- let deserializableRaw = raw as string;
1003
+ if (typeof raw !== "string") {
1004
+ lastExpiresAt = undefined;
1005
+ lastValue = ensureValidatedValue(defaultValue, false);
1006
+ hasLastValue = true;
1007
+ return lastValue;
1008
+ }
1009
+
1010
+ let deserializableRaw = raw;
886
1011
 
887
1012
  if (expiration) {
1013
+ let envelopeExpiresAt: number | null = null;
888
1014
  try {
889
- const parsed = JSON.parse(raw as string) as unknown;
1015
+ const parsed = JSON.parse(raw) as unknown;
890
1016
  if (isStoredEnvelope(parsed)) {
1017
+ envelopeExpiresAt = parsed.expiresAt;
891
1018
  if (parsed.expiresAt <= Date.now()) {
892
1019
  removeStoredRaw();
893
1020
  invalidateParsedCache();
894
1021
  onExpired?.(storageKey);
895
- lastValue = ensureValidatedValue(config.defaultValue, false);
1022
+ lastValue = ensureValidatedValue(defaultValue, false);
896
1023
  hasLastValue = true;
897
1024
  return lastValue;
898
1025
  }
@@ -902,6 +1029,9 @@ export function createStorageItem<T = undefined>(
902
1029
  } catch {
903
1030
  // Keep backward compatibility with legacy raw values.
904
1031
  }
1032
+ lastExpiresAt = envelopeExpiresAt;
1033
+ } else {
1034
+ lastExpiresAt = undefined;
905
1035
  }
906
1036
 
907
1037
  lastValue = ensureValidatedValue(deserialize(deserializableRaw), true);
@@ -910,11 +1040,7 @@ export function createStorageItem<T = undefined>(
910
1040
  };
911
1041
 
912
1042
  const set = (valueOrFn: T | ((prev: T) => T)): void => {
913
- const currentValue = get();
914
- const newValue =
915
- typeof valueOrFn === "function"
916
- ? (valueOrFn as (prev: T) => T)(currentValue)
917
- : valueOrFn;
1043
+ const newValue = isUpdater(valueOrFn) ? valueOrFn(get()) : valueOrFn;
918
1044
 
919
1045
  invalidateParsedCache();
920
1046
 
@@ -976,54 +1102,17 @@ export function createStorageItem<T = undefined>(
976
1102
  _hasExpiration: expiration !== undefined,
977
1103
  _readCacheEnabled: readCache,
978
1104
  _isBiometric: isBiometric,
979
- _secureAccessControl: secureAccessControl,
1105
+ ...(secureAccessControl !== undefined
1106
+ ? { _secureAccessControl: secureAccessControl }
1107
+ : {}),
980
1108
  scope: config.scope,
981
1109
  key: storageKey,
982
1110
  };
983
1111
 
984
- return storageItem as StorageItem<T>;
1112
+ return storageItem;
985
1113
  }
986
1114
 
987
- export function useStorage<T>(
988
- item: StorageItem<T>,
989
- ): [T, (value: T | ((prev: T) => T)) => void] {
990
- const value = useSyncExternalStore(item.subscribe, item.get, item.get);
991
- return [value, item.set];
992
- }
993
-
994
- export function useStorageSelector<T, TSelected>(
995
- item: StorageItem<T>,
996
- selector: (value: T) => TSelected,
997
- isEqual: (prev: TSelected, next: TSelected) => boolean = Object.is,
998
- ): [TSelected, (value: T | ((prev: T) => T)) => void] {
999
- const selectedRef = useRef<
1000
- { hasValue: false } | { hasValue: true; value: TSelected }
1001
- >({
1002
- hasValue: false,
1003
- });
1004
-
1005
- const getSelectedSnapshot = () => {
1006
- const nextSelected = selector(item.get());
1007
- const current = selectedRef.current;
1008
- if (current.hasValue && isEqual(current.value, nextSelected)) {
1009
- return current.value;
1010
- }
1011
-
1012
- selectedRef.current = { hasValue: true, value: nextSelected };
1013
- return nextSelected;
1014
- };
1015
-
1016
- const selectedValue = useSyncExternalStore(
1017
- item.subscribe,
1018
- getSelectedSnapshot,
1019
- getSelectedSnapshot,
1020
- );
1021
- return [selectedValue, item.set];
1022
- }
1023
-
1024
- export function useSetStorage<T>(item: StorageItem<T>) {
1025
- return item.set;
1026
- }
1115
+ export { useStorage, useStorageSelector, useSetStorage } from "./storage-hooks";
1027
1116
 
1028
1117
  type BatchReadItem<T> = Pick<
1029
1118
  StorageItem<T>,
@@ -1052,7 +1141,11 @@ export function getBatch(
1052
1141
  return items.map((item) => item.get());
1053
1142
  }
1054
1143
 
1055
- const useRawBatchPath = items.every((item) => canUseRawBatchPath(item));
1144
+ const useRawBatchPath = items.every((item) =>
1145
+ scope === StorageScope.Secure
1146
+ ? canUseSecureRawBatchPath(item)
1147
+ : canUseRawBatchPath(item),
1148
+ );
1056
1149
  if (!useRawBatchPath) {
1057
1150
  return items.map((item) => item.get());
1058
1151
  }
@@ -1086,6 +1179,9 @@ export function getBatch(
1086
1179
  fetchedValues.forEach((value, index) => {
1087
1180
  const key = keysToFetch[index];
1088
1181
  const targetIndex = keyIndexes[index];
1182
+ if (key === undefined || targetIndex === undefined) {
1183
+ return;
1184
+ }
1089
1185
  rawValues[targetIndex] = value;
1090
1186
  cacheRawValue(scope, key, value);
1091
1187
  });
@@ -1114,6 +1210,48 @@ export function setBatch<T>(
1114
1210
  return;
1115
1211
  }
1116
1212
 
1213
+ if (scope === StorageScope.Secure) {
1214
+ const secureEntries = items.map(({ item, value }) => ({
1215
+ item,
1216
+ value,
1217
+ internal: asInternal(item),
1218
+ }));
1219
+ const canUseSecureBatchPath = secureEntries.every(({ internal }) =>
1220
+ canUseSecureRawBatchPath(internal),
1221
+ );
1222
+ if (!canUseSecureBatchPath) {
1223
+ items.forEach(({ item, value }) => item.set(value));
1224
+ return;
1225
+ }
1226
+
1227
+ flushSecureWrites();
1228
+ const groupedByAccessControl = new Map<
1229
+ number,
1230
+ { keys: string[]; values: string[] }
1231
+ >();
1232
+
1233
+ secureEntries.forEach(({ item, value, internal }) => {
1234
+ const accessControl =
1235
+ internal._secureAccessControl ?? AccessControl.WhenUnlocked;
1236
+ const existingGroup = groupedByAccessControl.get(accessControl);
1237
+ const group = existingGroup ?? { keys: [], values: [] };
1238
+ group.keys.push(item.key);
1239
+ group.values.push(item.serialize(value));
1240
+ if (!existingGroup) {
1241
+ groupedByAccessControl.set(accessControl, group);
1242
+ }
1243
+ });
1244
+
1245
+ groupedByAccessControl.forEach((group, accessControl) => {
1246
+ WebStorage.setSecureAccessControl(accessControl);
1247
+ WebStorage.setBatch(group.keys, group.values, scope);
1248
+ group.keys.forEach((key, index) =>
1249
+ cacheRawValue(scope, key, group.values[index]),
1250
+ );
1251
+ });
1252
+ return;
1253
+ }
1254
+
1117
1255
  const useRawBatchPath = items.every(({ item }) =>
1118
1256
  canUseRawBatchPath(asInternal(item)),
1119
1257
  );
@@ -1124,9 +1262,6 @@ export function setBatch<T>(
1124
1262
 
1125
1263
  const keys = items.map((entry) => entry.item.key);
1126
1264
  const values = items.map((entry) => entry.item.serialize(entry.value));
1127
- if (scope === StorageScope.Secure) {
1128
- flushSecureWrites();
1129
- }
1130
1265
  WebStorage.setBatch(keys, values, scope);
1131
1266
  keys.forEach((key, index) => cacheRawValue(scope, key, values[index]));
1132
1267
  }
@@ -1267,20 +1402,28 @@ export function createSecureAuthStorage<K extends string>(
1267
1402
  options?: { namespace?: string },
1268
1403
  ): Record<K, StorageItem<string>> {
1269
1404
  const ns = options?.namespace ?? "auth";
1270
- const result = {} as Record<K, StorageItem<string>>;
1405
+ const result: Partial<Record<K, StorageItem<string>>> = {};
1271
1406
 
1272
- for (const key of Object.keys(config) as K[]) {
1407
+ for (const key of typedKeys(config)) {
1273
1408
  const itemConfig = config[key];
1409
+ const expirationConfig =
1410
+ itemConfig.ttlMs !== undefined ? { ttlMs: itemConfig.ttlMs } : undefined;
1274
1411
  result[key] = createStorageItem<string>({
1275
1412
  key,
1276
1413
  scope: StorageScope.Secure,
1277
1414
  defaultValue: "",
1278
1415
  namespace: ns,
1279
- biometric: itemConfig.biometric,
1280
- accessControl: itemConfig.accessControl,
1281
- expiration: itemConfig.ttlMs ? { ttlMs: itemConfig.ttlMs } : undefined,
1416
+ ...(itemConfig.biometric !== undefined
1417
+ ? { biometric: itemConfig.biometric }
1418
+ : {}),
1419
+ ...(itemConfig.accessControl !== undefined
1420
+ ? { accessControl: itemConfig.accessControl }
1421
+ : {}),
1422
+ ...(expirationConfig !== undefined
1423
+ ? { expiration: expirationConfig }
1424
+ : {}),
1282
1425
  });
1283
1426
  }
1284
1427
 
1285
- return result;
1428
+ return result as Record<K, StorageItem<string>>;
1286
1429
  }
@@ -0,0 +1,48 @@
1
+ import { useRef, useSyncExternalStore } from "react";
2
+
3
+ type HookStorageItem<T> = {
4
+ get: () => T;
5
+ set: (value: T | ((prev: T) => T)) => void;
6
+ subscribe: (callback: () => void) => () => void;
7
+ };
8
+
9
+ export function useStorage<T>(
10
+ item: HookStorageItem<T>,
11
+ ): [T, (value: T | ((prev: T) => T)) => void] {
12
+ const value = useSyncExternalStore(item.subscribe, item.get, item.get);
13
+ return [value, item.set];
14
+ }
15
+
16
+ export function useStorageSelector<T, TSelected>(
17
+ item: HookStorageItem<T>,
18
+ selector: (value: T) => TSelected,
19
+ isEqual: (prev: TSelected, next: TSelected) => boolean = Object.is,
20
+ ): [TSelected, (value: T | ((prev: T) => T)) => void] {
21
+ const selectedRef = useRef<
22
+ { hasValue: false } | { hasValue: true; value: TSelected }
23
+ >({
24
+ hasValue: false,
25
+ });
26
+
27
+ const getSelectedSnapshot = () => {
28
+ const nextSelected = selector(item.get());
29
+ const current = selectedRef.current;
30
+ if (current.hasValue && isEqual(current.value, nextSelected)) {
31
+ return current.value;
32
+ }
33
+
34
+ selectedRef.current = { hasValue: true, value: nextSelected };
35
+ return nextSelected;
36
+ };
37
+
38
+ const selectedValue = useSyncExternalStore(
39
+ item.subscribe,
40
+ getSelectedSnapshot,
41
+ getSelectedSnapshot,
42
+ );
43
+ return [selectedValue, item.set];
44
+ }
45
+
46
+ export function useSetStorage<T>(item: HookStorageItem<T>) {
47
+ return item.set;
48
+ }