From e9e435539481b9b775af76ad2fa4b544f65782ab Mon Sep 17 00:00:00 2001 From: Joao Paulo Costa Marra Date: Tue, 25 Aug 2026 10:53:52 -0300 Subject: [PATCH] Reuse cached storage misses --- CHANGELOG.md | 9 +- README.md | 40 ++-- apps/example/app/index.tsx | 32 +-- apps/example/components/shared.tsx | 100 ++++----- docs/api-reference.md | 77 +++++-- docs/benchmarks.md | 7 +- docs/recipes.md | 3 +- docs/secure-storage.md | 2 +- .../scripts/benchmark-check.js | 206 +++++++++++------- .../src/__tests__/storage.test.ts | 24 ++ .../src/__tests__/web-storage.test.ts | 16 +- .../src/storage-core.ts | 24 +- 12 files changed, 331 insertions(+), 209 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ad8281..35a0f0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,9 +10,9 @@ Breaking changes are always listed first in each release section. ### Breaking changes -- None. The Nitro Modules `0.37.x` native rebuild requirement remains from - 0.9.0; this release restores the previous set-item type and secure-write - defaults for existing consumers. +- Breaking changes: None. The Nitro Modules `0.37.x` native rebuild + requirement remains from 0.9.0; this release restores the previous set-item + type and secure-write defaults for existing consumers. ### Added @@ -28,6 +28,8 @@ Breaking changes are always listed first in each release section. `apply()` writes remain available through the explicit `storage.setSecureWritesAsync(true)` opt-in and can be drained with `storage.flushSecureWrites()`. +- Enabled raw read-cache lookups now reuse cached missing values in single and + batch reads without per-item fallback calls. ### Deprecated @@ -41,6 +43,7 @@ Breaking changes are always listed first in each release section. - Documented secure-storage recovery semantics and warned against cached fallback for authentication tokens unless stale credentials are an explicit application policy. +- Clarified isolated web benchmark limits and corrected capability/API examples. ## [0.9.0] - 2026-08-20 diff --git a/README.md b/README.md index f464887..dc2b87e 100644 --- a/README.md +++ b/README.md @@ -469,7 +469,9 @@ can also throw when a protected store is locked or its key is invalidated. Use matching item. `setBatch()` validates every item/value pair independently, including heterogeneous batches. Missing keys use each item's `defaultValue`; the native bridge preserves missing -entries as `undefined` while reading the batch. +entries as `undefined` while reading the batch. With `readCache: true`, item and +batch reads reuse raw cache entries, including cached missing values, until a +write, delete, clear, or external change invalidates the entry. ```ts import { getBatch, removeBatch, setBatch } from "react-native-nitro-storage"; @@ -592,6 +594,11 @@ setWebDiskStorageBackend(backend); setWebSecureStorageBackend(backend); ``` +Web reads and mutations stay synchronous against the backend's in-memory +contract; use `flushWebStorageBackends()` for asynchronous persistence +boundaries. The native entry keeps the web backend setters, getters, and flush +function as typed no-ops for cross-platform code. + Browser storage cannot provide iOS Keychain or Android Keystore guarantees. Web Secure scope is only as strong as the backend you configure. @@ -622,28 +629,25 @@ const { storage, memoryItem } = createNitroStorageMock(); ## API -The default export is a configured `storage` instance; `createStorage()` -builds isolated instances. Values are read and written through typed storage -items (`stringItem`, `numberItem`, `booleanItem`, `jsonItem`, plus custom -`createStorageItem` schemas) bound to a scope (`Memory`, `Disk`, or -`Secure`). The surface covers single-key operations (`get`/`set`/`remove`/ -`has`), batch reads and writes, prefixed key enumeration, size queries, -`flushSecureWrites()`, clear-by-scope, events and observers, React hooks, -transactional migrations with rename/rollback, and the web backend adapter -API. The full reference lives in +The package exposes named `storage`, `createStorageItem`, the scoped item +factories, `createSetItem`, batch operations, migration and transaction helpers, +secure-auth storage, React hooks, and web backend utilities. Values are bound to +`Memory`, `Disk`, or `Secure` and support typed single-key operations, raw +inspection, events and observers, cache and write-flush controls, secure +metadata, transactional migrations with rename/rollback, and configurable web +backends. The full reference lives in [docs/api-reference.md](docs/api-reference.md). ## Error Contract -Native failures cross the bridge as tagged, deterministic errors and surface -as typed `StorageError` values with stable string codes — identical codes on -iOS, Android, and web. Use `isStorageError(error, code)` to branch on them: +Native and web adapters tag classified failures with stable error codes. Use +`getStorageErrorCode(error)` or `isStorageError(error, code)` to branch on them: `keychain_locked` reports a locked Keychain that a retry can recover after authentication, secure-scope write or biometric failures carry their own codes, and invalid inputs (bad scope, malformed keys, numeric guard -violations) are rejected before reaching native storage. Errors never -swallow the underlying cause silently: the original platform message is -preserved on the error for diagnostics. +violations) are rejected before reaching native storage. Errors never swallow +the underlying cause silently: the original platform message is preserved on +the error for diagnostics. ## Platform Support @@ -702,6 +706,10 @@ Run native example builds before release when changing plugin, native, Nitro, secure storage, or packaging files. The package release path also validates package contents and dry-run publish behavior. +`bun run benchmark` measures only the built web entry with an isolated private +localStorage implementation; it is not a native Disk or Secure benchmark. See +[docs/benchmarks.md](docs/benchmarks.md) for sampling and interpretation limits. + ## License [MIT](LICENSE) diff --git a/apps/example/app/index.tsx b/apps/example/app/index.tsx index 31caa80..ff28add 100644 --- a/apps/example/app/index.tsx +++ b/apps/example/app/index.tsx @@ -1,4 +1,4 @@ -import { useRef, useState } from "react"; +import { memo, useRef, useState } from "react"; import { Platform, StyleSheet, Text, View } from "react-native"; import { createSecureAuthStorage, @@ -38,6 +38,11 @@ import { } from "../components/shared"; import { SmokeTestRunner } from "../components/smoke-test"; +const MemoizedAdvancedApiDemo = memo(AdvancedApiDemo); +const MemoizedErgonomicsDemo = memo(ErgonomicsDemo); +const MemoizedKeychainLifecycleProbe = memo(KeychainLifecycleProbe); +const MemoizedSmokeTestRunner = memo(SmokeTestRunner); + const counterItem = createStorageItem({ key: "counter", scope: StorageScope.Memory, @@ -282,7 +287,7 @@ function runRuntimeBenchmark() { } } -function RuntimeBenchmarkCard() { +const RuntimeBenchmarkCard = memo(function RuntimeBenchmarkCard() { const [runtimeBenchmarkResult, setRuntimeBenchmarkResult] = useState("(not run)"); @@ -310,12 +315,13 @@ function RuntimeBenchmarkCard() { ); -} +}); export default function HomeScreen() { const [counter, setCounter] = useStorage(counterItem); const [diskName, setDiskName] = useStorage(diskNameItem); + const hasDiskName = diskNameItem.has(); const [tempDiskName, setTempDiskName] = useState(""); const tempDiskNameRef = useRef(""); @@ -369,12 +375,8 @@ export default function HomeScreen() { storage.size(StorageScope.Memory), ); - const [scopeDiskSize, setScopeDiskSize] = useState(() => - storage.size(StorageScope.Disk), - ); - const [scopeMemorySize, setScopeMemorySize] = useState(() => - storage.size(StorageScope.Memory), - ); + const [scopeDiskSize, setScopeDiskSize] = useState(diskSize); + const [scopeMemorySize, setScopeMemorySize] = useState(memorySize); const [rawValue, setRawValue] = useState(); @@ -448,12 +450,12 @@ export default function HomeScreen() { return ( - + - + - - + + diff --git a/apps/example/components/shared.tsx b/apps/example/components/shared.tsx index 9b7d2c8..69c97ba 100644 --- a/apps/example/components/shared.tsx +++ b/apps/example/components/shared.tsx @@ -334,7 +334,47 @@ export const Page = ({ ); }; +const consumerStyles = { + row: { + flexDirection: "row", + alignItems: "center", + gap: 10, + }, + flex1: { + flex: 1, + }, + panel: { + backgroundColor: Colors.card, + borderWidth: 1, + borderColor: Colors.border, + borderRadius: 12, + padding: 12, + gap: 8, + }, + panelTitle: { + color: Colors.muted, + fontFamily: fontSans700, + fontSize: 11, + textTransform: "uppercase", + letterSpacing: 0.8, + }, + panelValue: { + color: Colors.text, + fontFamily: fontSans800, + fontSize: 42, + lineHeight: 44, + textAlign: "center", + }, + helperText: { + color: Colors.muted, + fontFamily: fontSans400, + fontSize: 12, + lineHeight: 18, + }, +} as const; + export const styles = StyleSheet.create({ + ...consumerStyles, container: { flex: 1, backgroundColor: Colors.background, @@ -552,12 +592,6 @@ export const styles = StyleSheet.create({ lineHeight: 18, color: "#cbd5e1", }, - codeText: { - fontFamily: fontMono400, - fontSize: 12, - lineHeight: 18, - color: Colors.text, - }, section: { gap: 10, }, @@ -569,58 +603,4 @@ export const styles = StyleSheet.create({ letterSpacing: 1, marginTop: 4, }, - row: { - flexDirection: "row", - alignItems: "center", - gap: 10, - }, - grid: { - flexDirection: "row", - alignItems: "center", - flexWrap: "wrap", - gap: 8, - }, - flex1: { - flex: 1, - }, - panel: { - backgroundColor: Colors.card, - borderWidth: 1, - borderColor: Colors.border, - borderRadius: 12, - padding: 12, - gap: 8, - }, - panelTitle: { - color: Colors.muted, - fontFamily: fontSans700, - fontSize: 11, - textTransform: "uppercase", - letterSpacing: 0.8, - }, - panelValue: { - color: Colors.text, - fontFamily: fontSans800, - fontSize: 42, - lineHeight: 44, - textAlign: "center", - }, - helperText: { - color: Colors.muted, - fontFamily: fontSans400, - fontSize: 12, - lineHeight: 18, - }, }); - -const sharedStyleKeysForLint = [ - styles.codeText, - styles.row, - styles.grid, - styles.flex1, - styles.panel, - styles.panelTitle, - styles.panelValue, - styles.helperText, -]; -void sharedStyleKeysForLint; diff --git a/docs/api-reference.md b/docs/api-reference.md index 30085e1..96cb200 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -14,24 +14,28 @@ const item = createStorageItem({ `StorageItemConfig`: -| Field | Type | Purpose | -| ---------------------- | -------------------------------- | ---------------------------------------------------------------- | -| `key` | `string` | Storage key. Combined with `namespace` when provided. | -| `scope` | `StorageScope` | Memory, Disk, or Secure. | -| `defaultValue` | `T` | Value returned when no stored value exists. | -| `serialize` | `(value: T) => string` | Custom string encoder. Defaults to primitive/JSON serialization. | -| `deserialize` | `(value: string) => T` | Custom string decoder. | -| `validate` | `(value: unknown) => value is T` | Runtime guard for stored data. | -| `onValidationError` | `(invalidValue: unknown) => T` | Replacement value when validation fails. | -| `expiration` | `{ ttlMs: number }` | Time-to-live for the value. | -| `onExpired` | `(key: string) => void` | Called when a read detects TTL expiry. | -| `readCache` | `boolean` | Cache parsed values in memory. | -| `coalesceDiskWrites` | `boolean` | Buffer Disk writes until the next flush. | -| `coalesceSecureWrites` | `boolean` | Buffer Secure writes until the next flush. | -| `namespace` | `string` | Prefix keys as `namespace:key`. | -| `biometric` | `boolean` | Store through biometric secure storage. | -| `biometricLevel` | `BiometricLevel` | Require biometric/passcode or biometric-only access. | -| `accessControl` | `AccessControl` | Platform secure accessibility setting. | +| Field | Type | Purpose | +| ---------------------------- | -------------------------------- | ------------------------------------------------------------------- | +| `key` | `string` | Storage key. Combined with `namespace` when provided. | +| `scope` | `StorageScope` | Memory, Disk, or Secure. | +| `defaultValue` | `T` | Value returned when no stored value exists. | +| `serialize` | `(value: T) => string` | Custom string encoder. Defaults to primitive/JSON serialization. | +| `deserialize` | `(value: string) => T` | Custom string decoder. | +| `validate` | `(value: unknown) => value is T` | Runtime guard for stored data. | +| `onValidationError` | `(invalidValue: unknown) => T` | Replacement value when validation fails. | +| `expiration` | `{ ttlMs: number }` | Time-to-live for the value. | +| `onExpired` | `(key: string) => void` | Called when a read detects TTL expiry. | +| `readCache` | `boolean` | Reuse raw cache entries for reads, including cached missing values. | +| `coalesceDiskWrites` | `boolean` | Buffer Disk writes until the next flush. | +| `coalesceSecureWrites` | `boolean` | Buffer Secure writes until the next flush. | +| `namespace` | `string` | Prefix keys as `namespace:key`. | +| `biometric` | `boolean` | Store through biometric secure storage. | +| `biometricLevel` | `BiometricLevel` | Require biometric/passcode or biometric-only access. | +| `accessControl` | `AccessControl` | Platform secure accessibility setting. | +| `group` | `string` | Register the item for group cleanup and inspection. | +| `renameFrom` | `string \| readonly string[]` | Copy a legacy key on first read, then remove it. | +| `fallbackToCacheOnReadError` | `boolean` | Return the last cached value when a backend read fails. | +| `onReadError` | `(error: unknown) => void` | Observe a backend read failure before fallback or rethrow. | `StorageItem`: @@ -41,6 +45,9 @@ const item = createStorageItem({ | `getWithVersion()` | Return `{ value, version }` for optimistic writes. | | `set(value)` | Store a value. Accepts direct values or updater functions. | | `setIfVersion(version, value)` | Store only when the current version still matches. | +| `merge(partial)` | Shallow-merge an object value. | +| `reset()` | Delete the key so the next read returns the default. | +| `setOrDelete(value)` | Set a value or delete for `null`/`undefined`. | | `delete()` | Remove the key. | | `has()` | Check whether the key exists. | | `subscribe(callback)` | Subscribe to item changes. Returns an unsubscribe function. | @@ -58,6 +65,23 @@ const unsubscribe = profileItem.subscribeSelector( ); ``` +## createSetItem + +```ts +const flags = createSetItem<"beta" | "compact">({ + key: "flags", + scope: StorageScope.Memory, + defaultValue: ["compact"], +}); + +flags.add("beta"); +flags.has("compact"); +flags.getTyped(); +``` + +`get()` retains the compatibility shape `Record`. Use +`getTyped()` when a precise member union is useful. + ## React Hooks ```ts @@ -74,9 +98,14 @@ See [react-hooks.md](react-hooks.md). | Method | Purpose | | ------------------------------------------------ | ----------------------------------------------------------------------------------------- | -| `clear(scope)` | Clear one scope. | +| `clear(scope, options?)` | Clear one scope, optionally preserving selected keys. | | `clearAll()` | Clear Memory, Disk, and Secure scopes. | | `clearNamespace(namespace, scope)` | Remove keys under `namespace:`. | +| `clearGroup(group)` | Remove registered items in a group across their scopes. | +| `getGroupItems(group)` | List registered items in a group. | +| `subscribeExpired(scope, listener)` | Receive item events caused by TTL expiry. | +| `findDuplicateKeys()` | Find duplicate registered `(scope, key)` definitions. | +| `getRegisteredKeys()` | List registered `(scope, key)` definitions. | | `subscribe(scope, listener)` | Subscribe to raw scope-level change events. | | `subscribeKey(scope, key, listener)` | Subscribe to raw events for one key. | | `subscribePrefix(scope, prefix, listener)` | Subscribe to raw events for matching key prefixes. | @@ -97,6 +126,7 @@ See [react-hooks.md](react-hooks.md). | `setKeychainAccessGroup(group)` | Configure iOS Keychain access group. | | `setMetricsObserver(observer)` | Receive operation timing events. | | `getMetricsSnapshot()` | Read aggregated metrics. | +| `getScopedMetricsSnapshot()` | Read metrics grouped by storage scope. | | `resetMetrics()` | Clear metrics counters. | | `getCapabilities()` | Read runtime storage capabilities. | | `getSecurityCapabilities()` | Read secure backend capability metadata. | @@ -170,6 +200,11 @@ setBatch( removeBatch([themeItem, localeItem], StorageScope.Disk); ``` +`getBatch()` reuses enabled raw cache entries, including cached missing values, +and returns each item's default for a missing raw value without issuing a +per-item fallback read. Items that need validation, expiration, or migration +use their item-level read path to preserve those rules. + See [batch-transactions-migrations.md](batch-transactions-migrations.md). ## Transactions @@ -238,6 +273,10 @@ getWebSecureStorageBackend(); await flushWebStorageBackends(); ``` +The web entry also exports `describeWebBackendCapabilities(backend)` and +`isIndexedDBWebBackend(backend)`. The native entry keeps the web backend +setters, getters, and flush function as typed no-ops for shared code. + See [web-backends.md](web-backends.md). ## Enums diff --git a/docs/benchmarks.md b/docs/benchmarks.md index ee8aeef..ab51e8a 100644 --- a/docs/benchmarks.md +++ b/docs/benchmarks.md @@ -11,13 +11,18 @@ bun run benchmark ## Scope: Web Only -`benchmark` loads `lib/commonjs/index.web.js` and measures the web entry against the localStorage backend. The `disk:` and `secure:` labels describe web scopes, not native Disk or Secure storage. +`benchmark` loads only this package's `lib/commonjs/index.web.js` entry and measures it against a private localStorage implementation created for that process. The `web:disk-scope:` and `web:secure-scope:` labels describe web scopes, not native Disk or Secure storage. Native Disk/Secure baselines require a device or simulator run and are not part of this gate. Do not compare these numbers against native storage. ## Interpreting Results +- Each run reports the package name/version, runtime, architecture, warmups, + sample count, median, and p95. It does not use another package's artifact or + ambient browser storage. - Compare results on the same machine and Node/Bun version. +- The benchmark uses seven measured samples after two warmups and reports the + median for throughput. It does not select the best sample. - Treat large deltas as a prompt to inspect recent storage-runtime, serialization, cache, or event changes. - Do not compare web backend numbers against native secure storage numbers; they measure different systems. diff --git a/docs/recipes.md b/docs/recipes.md index 05e3e93..a561ca0 100644 --- a/docs/recipes.md +++ b/docs/recipes.md @@ -283,10 +283,9 @@ Use `subscribePrefix()` or `subscribeNamespace()` for targeted integrations. Use ## Capability Checks ```ts -const capabilities = storage.getCapabilities(); const security = storage.getSecurityCapabilities(); -if (security.secureStorage !== "available") { +if (security.secureStorage.encrypted !== "available") { console.warn("Secure storage is not available on this runtime"); } ``` diff --git a/docs/secure-storage.md b/docs/secure-storage.md index 3730e81..c482b8e 100644 --- a/docs/secure-storage.md +++ b/docs/secure-storage.md @@ -98,7 +98,7 @@ import { storage } from "react-native-nitro-storage"; const capabilities = storage.getSecurityCapabilities(); -if (capabilities.secureStorage === "available") { +if (capabilities.secureStorage.encrypted === "available") { // Secure scope is backed by the configured native or web secure backend. } ``` diff --git a/packages/react-native-nitro-storage/scripts/benchmark-check.js b/packages/react-native-nitro-storage/scripts/benchmark-check.js index 319e6b7..1f8ab2e 100644 --- a/packages/react-native-nitro-storage/scripts/benchmark-check.js +++ b/packages/react-native-nitro-storage/scripts/benchmark-check.js @@ -3,6 +3,7 @@ const fs = require("fs"); const { performance } = require("perf_hooks"); const packageRoot = path.join(__dirname, ".."); +const packageManifest = require(path.join(packageRoot, "package.json")); const entrypointPath = path.join( packageRoot, "lib", @@ -10,13 +11,50 @@ const entrypointPath = path.join( "index.web.js", ); -let storageModule; +if (packageManifest.name !== "react-native-nitro-storage") { + console.error( + `Benchmark setup failed: expected react-native-nitro-storage, got ${packageManifest.name}.`, + ); + process.exit(1); +} + if (!fs.existsSync(entrypointPath)) { console.error("Benchmark setup failed: build artifacts were not found."); console.error("Run `bun run build` before running `bun run benchmark`."); process.exit(1); } +function createIsolatedLocalStorage() { + const store = new Map(); + return { + clear() { + store.clear(); + }, + getItem(key) { + return store.has(key) ? store.get(key) : null; + }, + key(index) { + return Array.from(store.keys())[index] ?? null; + }, + removeItem(key) { + store.delete(key); + }, + setItem(key, value) { + store.set(key, String(value)); + }, + get length() { + return store.size; + }, + }; +} + +Object.defineProperty(globalThis, "localStorage", { + value: createIsolatedLocalStorage(), + configurable: true, + writable: true, +}); + +let storageModule; try { storageModule = require(entrypointPath); } catch (error) { @@ -34,71 +72,58 @@ const { storage, } = storageModule; +console.log(`Benchmark package: ${packageManifest.name}@${packageManifest.version}`); console.log( - "Benchmark scope: web-only (lib/commonjs/index.web.js with the localStorage backend).", + "Benchmark scope: isolated Node web adapter with a private in-memory localStorage implementation.", ); console.log( - "Native Disk/Secure baselines require a device run and are not part of this gate.", + "Disk/Secure labels below are web scopes backed by the same private adapter; they are not native storage measurements.", ); console.log(""); -function ensureLocalStorage() { - if (typeof globalThis.localStorage !== "undefined") { - return; - } - - const store = new Map(); - const localStorageMock = { - clear() { - store.clear(); - }, - getItem(key) { - return store.has(key) ? store.get(key) : null; - }, - key(index) { - return Array.from(store.keys())[index] ?? null; - }, - removeItem(key) { - store.delete(key); - }, - setItem(key, value) { - store.set(key, String(value)); - }, - get length() { - return store.size; - }, - }; - - Object.defineProperty(globalThis, "localStorage", { - value: localStorageMock, - configurable: true, - writable: true, - }); +function percentile(values, percentileValue) { + const sorted = [...values].sort((a, b) => a - b); + const position = (sorted.length - 1) * percentileValue; + const lower = Math.floor(position); + const upper = Math.ceil(position); + if (lower === upper) return sorted[lower]; + return sorted[lower] + (sorted[upper] - sorted[lower]) * (position - lower); } -function measure(label, operations, run) { - const start = performance.now(); - run(); - const durationMs = performance.now() - start; - const opsPerSecond = operations / (durationMs / 1000); - return { label, durationMs, opsPerSecond }; -} +function measureSamples(label, operations, run, samples = 7, warmup = 2) { + for (let index = 0; index < warmup; index += 1) { + run(); + } -function measureBestOf(label, operations, run, samples = 3) { - let best = measure(label, operations, run); - for (let sample = 1; sample < samples; sample += 1) { - const metric = measure(label, operations, run); - if (metric.opsPerSecond > best.opsPerSecond) { - best = metric; - } + const durations = []; + for (let index = 0; index < samples; index += 1) { + const start = performance.now(); + run(); + durations.push(performance.now() - start); } - return best; + + const totalMs = durations.reduce((sum, duration) => sum + duration, 0); + const medianMs = percentile(durations, 0.5); + return { + label, + operations, + samples, + warmup, + meanMs: totalMs / samples, + medianMs, + p95Ms: percentile(durations, 0.95), + minMs: Math.min(...durations), + maxMs: Math.max(...durations), + opsPerSecond: operations / (medianMs / 1000), + }; } function printMetric(metric) { - const roundedMs = metric.durationMs.toFixed(2); + const roundedMs = metric.medianMs.toFixed(2); const roundedOps = Math.round(metric.opsPerSecond).toLocaleString(); - console.log(`${metric.label}: ${roundedMs}ms (${roundedOps} ops/s)`); + console.log( + `${metric.label}: median=${roundedMs}ms p95=${metric.p95Ms.toFixed(2)}ms (${roundedOps} ops/s)`, + ); } const thresholds = { @@ -112,24 +137,32 @@ const thresholds = { secureGetOpsPerSecond: 150_000, }; -ensureLocalStorage(); storage.clearAll(); +const benchmarkNamespace = `__nitro_storage_benchmark_${process.pid}__`; +const benchmarkKey = (name) => `${benchmarkNamespace}${name}`; + +const resetStorage = () => { + storage.clearAll(); +}; + const memoryCounter = createStorageItem({ - key: "__benchmark_memory_counter__", + key: benchmarkKey("memory_counter"), scope: StorageScope.Memory, defaultValue: 0, }); const setIterations = 40_000; -const setMetric = measureBestOf("memory:set", setIterations, () => { +resetStorage(); +const setMetric = measureSamples("web:memory:set", setIterations, () => { for (let index = 0; index < setIterations; index += 1) { memoryCounter.set(index); } }); const getIterations = 80_000; -const getMetric = measureBestOf("memory:get", getIterations, () => { +resetStorage(); +const getMetric = measureSamples("web:memory:get", getIterations, () => { for (let index = 0; index < getIterations; index += 1) { memoryCounter.get(); } @@ -137,7 +170,7 @@ const getMetric = measureBestOf("memory:get", getIterations, () => { const batchItems = Array.from({ length: 32 }, (_, index) => createStorageItem({ - key: `__benchmark_batch_${index}__`, + key: benchmarkKey(`batch_${index}`), scope: StorageScope.Memory, defaultValue: 0, }), @@ -148,8 +181,9 @@ const batchPayload = batchItems.map((item, index) => ({ })); const batchIterations = 400; const batchOperationsPerIteration = batchItems.length * 3; -const batchMetric = measureBestOf( - "memory:batch-set-get-remove", +resetStorage(); +const batchMetric = measureSamples( + "web:memory:batch-set-get-remove", batchIterations * batchOperationsPerIteration, () => { for (let iteration = 0; iteration < batchIterations; iteration += 1) { @@ -161,44 +195,56 @@ const batchMetric = measureBestOf( ); const diskCounter = createStorageItem({ - key: "__benchmark_disk_counter__", + key: benchmarkKey("disk_counter"), scope: StorageScope.Disk, defaultValue: 0, }); const diskSetIterations = 25_000; -const diskSetMetric = measureBestOf("disk:set", diskSetIterations, () => { +resetStorage(); +const diskSetMetric = measureSamples("web:disk-scope:set", diskSetIterations, () => { for (let index = 0; index < diskSetIterations; index += 1) { diskCounter.set(index); } }); const diskGetIterations = 25_000; -const diskGetMetric = measureBestOf("disk:get", diskGetIterations, () => { +resetStorage(); +const diskGetMetric = measureSamples("web:disk-scope:get", diskGetIterations, () => { for (let index = 0; index < diskGetIterations; index += 1) { diskCounter.get(); } }); const secureCounter = createStorageItem({ - key: "__benchmark_secure_counter__", + key: benchmarkKey("secure_counter"), scope: StorageScope.Secure, defaultValue: 0, }); const secureSetIterations = 15_000; -const secureSetMetric = measureBestOf("secure:set", secureSetIterations, () => { - for (let index = 0; index < secureSetIterations; index += 1) { - secureCounter.set(index); - } -}); +resetStorage(); +const secureSetMetric = measureSamples( + "web:secure-scope:set", + secureSetIterations, + () => { + for (let index = 0; index < secureSetIterations; index += 1) { + secureCounter.set(index); + } + }, +); const secureGetIterations = 15_000; -const secureGetMetric = measureBestOf("secure:get", secureGetIterations, () => { - for (let index = 0; index < secureGetIterations; index += 1) { - secureCounter.get(); - } -}); +resetStorage(); +const secureGetMetric = measureSamples( + "web:secure-scope:get", + secureGetIterations, + () => { + for (let index = 0; index < secureGetIterations; index += 1) { + secureCounter.get(); + } + }, +); const metrics = [ setMetric, @@ -212,6 +258,20 @@ const metrics = [ console.log("Web (localStorage) results:"); metrics.forEach(printMetric); +console.log( + `BENCHMARK_RESULT ${JSON.stringify({ + package: packageManifest.name, + version: packageManifest.version, + benchmark: "web-storage", + scope: "node-private-localStorage", + native: false, + metrics, + runtime: process.version, + platform: process.platform, + architecture: process.arch, + })}`, +); + const failures = []; if (setMetric.opsPerSecond < thresholds.memorySetOpsPerSecond) { failures.push( diff --git a/packages/react-native-nitro-storage/src/__tests__/storage.test.ts b/packages/react-native-nitro-storage/src/__tests__/storage.test.ts index 859485a..64a22e4 100644 --- a/packages/react-native-nitro-storage/src/__tests__/storage.test.ts +++ b/packages/react-native-nitro-storage/src/__tests__/storage.test.ts @@ -852,6 +852,30 @@ describe("useStorage", () => { expect(mockHybridObject.get).toHaveBeenCalledTimes(1); }); + it("reuses cached missing values for item and batch reads", () => { + const firstItem = createStorageItem({ + key: "cache-missing", + scope: StorageScope.Disk, + defaultValue: "first-default", + readCache: true, + }); + + mockHybridObject.get.mockReturnValue(undefined); + expect(firstItem.get()).toBe("first-default"); + + const secondItem = createStorageItem({ + key: "cache-missing", + scope: StorageScope.Disk, + defaultValue: "second-default", + readCache: true, + }); + + const values = getBatch([secondItem], StorageScope.Disk); + expect(values).toEqual(["second-default"]); + expect(mockHybridObject.get).toHaveBeenCalledTimes(1); + expect(mockHybridObject.getBatch).not.toHaveBeenCalled(); + }); + it("keeps read-through cache disabled by default", () => { const item = createStorageItem({ key: "cache-disabled", diff --git a/packages/react-native-nitro-storage/src/__tests__/web-storage.test.ts b/packages/react-native-nitro-storage/src/__tests__/web-storage.test.ts index 8ad3f31..376f266 100644 --- a/packages/react-native-nitro-storage/src/__tests__/web-storage.test.ts +++ b/packages/react-native-nitro-storage/src/__tests__/web-storage.test.ts @@ -1434,6 +1434,20 @@ describe("Web Storage", () => { expect(getBatch([cachedDisk], StorageScope.Disk)).toEqual(["cached-value"]); expect(diskGetSpy).toHaveBeenCalledTimes(0); + const cachedMissing = createStorageItem({ + key: "disk-batch-cache-missing", + scope: StorageScope.Disk, + defaultValue: "missing-default", + readCache: true, + }); + expect(cachedMissing.get()).toBe("missing-default"); + diskGetSpy.mockClear(); + + expect(getBatch([cachedMissing], StorageScope.Disk)).toEqual([ + "missing-default", + ]); + expect(diskGetSpy).toHaveBeenCalledTimes(0); + const pendingSecure = createStorageItem({ key: "secure-batch-pending", scope: StorageScope.Secure, @@ -1448,7 +1462,7 @@ describe("Web Storage", () => { await Promise.resolve(); }); - it("falls back to item.get in web getBatch when raw value is missing", () => { + it("returns the item default in web getBatch when raw value is missing", () => { const item = createStorageItem({ key: "web-batch-fallback", scope: StorageScope.Disk, diff --git a/packages/react-native-nitro-storage/src/storage-core.ts b/packages/react-native-nitro-storage/src/storage-core.ts index d85e4d9..38ab347 100644 --- a/packages/react-native-nitro-storage/src/storage-core.ts +++ b/packages/react-native-nitro-storage/src/storage-core.ts @@ -393,14 +393,6 @@ export function createStorageCore( return getCachedRawValueEntry(scope, key)?.get(representation); } - function hasCachedRawValue( - scope: NonMemoryScope, - key: string, - representation: StorageRawCacheRepresentation = "plain", - ): boolean { - return getCachedRawValueEntry(scope, key)?.has(representation) ?? false; - } - function invalidateRawCache(scope: NonMemoryScope, key: string): void { getScopeRawCache(scope).delete(key); } @@ -1699,13 +1691,9 @@ export function createStorageCore( if (readCache) { const scope = resolveNonMemoryScope(); - const cached = readCachedRawValue( - scope, - storageKey, - rawCacheRepresentation, - ); - if (hasCachedRawValue(scope, storageKey, rawCacheRepresentation)) { - return cached; + const cachedEntry = getCachedRawValueEntry(scope, storageKey); + if (cachedEntry?.has(rawCacheRepresentation)) { + return cachedEntry.get(rawCacheRepresentation); } } @@ -2895,9 +2883,9 @@ export function createStorageCore( } if (item._readCacheEnabled === true) { - const cached = readCachedRawValue(scope, item.key); - if (hasCachedRawValue(scope, item.key)) { - rawValues[index] = cached; + const cachedEntry = getCachedRawValueEntry(scope, item.key); + if (cachedEntry?.has("plain")) { + rawValues[index] = cachedEntry.get("plain"); return; } }