From f1a8366c2db8d987b141ead40cd4cd0baeec678d Mon Sep 17 00:00:00 2001 From: hairyf Date: Mon, 7 Sep 2026 11:30:20 +0800 Subject: [PATCH] feat(drivers): add React Native AsyncStorage driver --- docs/2.drivers/0.index.md | 1 + docs/2.drivers/react-native-async-storage.md | 28 +++++++ src/_drivers.ts | 13 +++- src/drivers/react-native-async-storage.ts | 77 +++++++++++++++++++ src/react-native-async-storage.d.ts | 12 +++ .../react-native-async-storage.test.ts | 40 ++++++++++ 6 files changed, 170 insertions(+), 1 deletion(-) create mode 100644 docs/2.drivers/react-native-async-storage.md create mode 100644 src/drivers/react-native-async-storage.ts create mode 100644 src/react-native-async-storage.d.ts create mode 100644 test/drivers/react-native-async-storage.test.ts diff --git a/docs/2.drivers/0.index.md b/docs/2.drivers/0.index.md index 57391e518..ee955306b 100644 --- a/docs/2.drivers/0.index.md +++ b/docs/2.drivers/0.index.md @@ -32,6 +32,7 @@ Without a `driver` option, `createStorage()` uses the [memory driver](/drivers/m | [Session storage](/drivers/browser) | `unstorage/drivers/session-storage` | Browser `sessionStorage`. | | [IndexedDB](/drivers/browser#indexeddb) | `unstorage/drivers/indexeddb` | Browser database through `idb-keyval`. | | [Capacitor Preferences](/drivers/capacitor-preferences) | `unstorage/drivers/capacitor-preferences` | Mobile preferences and web fallback. | +| [React Native AsyncStorage](/drivers/react-native-async-storage) | `unstorage/drivers/react-native-async-storage` | React Native persistent key-value storage. | | [Deno KV](/drivers/deno) | `unstorage/drivers/deno-kv` | Deno runtime and Deno Deploy. | | [Deno KV for Node.js](/drivers/deno#usage-nodejs) | `unstorage/drivers/deno-kv-node` | Remote or local Deno KV through `@deno/kv`. | | [LRU Cache](/drivers/lru-cache) | `unstorage/drivers/lru-cache` | Bounded in-process cache. | diff --git a/docs/2.drivers/react-native-async-storage.md b/docs/2.drivers/react-native-async-storage.md new file mode 100644 index 000000000..661bcc32c --- /dev/null +++ b/docs/2.drivers/react-native-async-storage.md @@ -0,0 +1,28 @@ +--- +icon: simple-icons:react +--- + +# React Native AsyncStorage + +Use [React Native AsyncStorage](https://github.com/react-native-async-storage/async-storage) as an Unstorage driver. + +## Usage + +```ts +import asyncStorageDriver from "unstorage/drivers/react-native-async-storage"; + +const storage = createStorage({ + driver: asyncStorageDriver({ + base: "app", + }), +}); +``` + +Install the peer dependency with `:pm-install{name="@react-native-async-storage/async-storage"}`. + +## Options + +- `base`: Optional namespace prefix for stored keys. +- `lib`: Optional AsyncStorage module (or loader) for bundlers and dependency injection. + +AsyncStorage does not provide change notifications, so this driver does not support `watch`. diff --git a/src/_drivers.ts b/src/_drivers.ts index 72c2b49f3..7f0d77494 100644 --- a/src/_drivers.ts +++ b/src/_drivers.ts @@ -27,6 +27,7 @@ import type { MongoDbOptions as MongodbOptions } from "unstorage/drivers/mongodb import type { NetlifyStoreOptions as NetlifyBlobsOptions } from "unstorage/drivers/netlify-blobs"; import type { OverlayStorageOptions as OverlayOptions } from "unstorage/drivers/overlay"; import type { PlanetscaleDriverOptions as PlanetscaleOptions } from "unstorage/drivers/planetscale"; +import type { ReactNativeAsyncStorageOptions } from "unstorage/drivers/react-native-async-storage"; import type { RedisOptions } from "unstorage/drivers/redis"; import type { S3DriverOptions as S3Options } from "unstorage/drivers/s3"; import type { SessionStorageOptions } from "unstorage/drivers/session-storage"; @@ -35,7 +36,7 @@ import type { UpstashOptions } from "unstorage/drivers/upstash"; import type { VercelBlobOptions } from "unstorage/drivers/vercel-blob"; import type { VercelCacheOptions as VercelRuntimeCacheOptions } from "unstorage/drivers/vercel-runtime-cache"; -export type BuiltinDriverName = "azure-app-configuration" | "azureAppConfiguration" | "azure-cosmos" | "azureCosmos" | "azure-key-vault" | "azureKeyVault" | "azure-storage-blob" | "azureStorageBlob" | "azure-storage-table" | "azureStorageTable" | "capacitor-preferences" | "capacitorPreferences" | "cloudflare-cache-binding" | "cloudflareCacheBinding" | "cloudflare-kv-binding" | "cloudflareKVBinding" | "cloudflare-kv-http" | "cloudflareKVHttp" | "cloudflare-r2-binding" | "cloudflareR2Binding" | "db0" | "deno-kv-node" | "denoKVNode" | "deno-kv" | "denoKV" | "fs-lite" | "fsLite" | "fs" | "github" | "http" | "indexedb" | "localstorage" | "lru-cache" | "lruCache" | "memory" | "mongodb" | "netlify-blobs" | "netlifyBlobs" | "null" | "overlay" | "planetscale" | "redis" | "s3" | "session-storage" | "sessionStorage" | "uploadthing" | "upstash" | "vercel-blob" | "vercelBlob" | "vercel-runtime-cache" | "vercelRuntimeCache"; +export type BuiltinDriverName = "azure-app-configuration" | "azureAppConfiguration" | "azure-cosmos" | "azureCosmos" | "azure-key-vault" | "azureKeyVault" | "azure-storage-blob" | "azureStorageBlob" | "azure-storage-table" | "azureStorageTable" | "capacitor-preferences" | "capacitorPreferences" | "cloudflare-cache-binding" | "cloudflareCacheBinding" | "cloudflare-kv-binding" | "cloudflareKVBinding" | "cloudflare-kv-http" | "cloudflareKVHttp" | "cloudflare-r2-binding" | "cloudflareR2Binding" | "db0" | "deno-kv-node" | "denoKVNode" | "deno-kv" | "denoKV" | "fs-lite" | "fsLite" | "fs" | "github" | "http" | "indexedb" | "localstorage" | "lru-cache" | "lruCache" | "memory" | "mongodb" | "netlify-blobs" | "netlifyBlobs" | "null" | "overlay" | "planetscale" | "react-native-async-storage" | "reactNativeAsyncStorage" | "redis" | "s3" | "session-storage" | "sessionStorage" | "uploadthing" | "upstash" | "vercel-blob" | "vercelBlob" | "vercel-runtime-cache" | "vercelRuntimeCache"; export type BuiltinDriverOptions = { "azure-app-configuration": AzureAppConfigurationOptions; @@ -77,6 +78,8 @@ export type BuiltinDriverOptions = { "netlifyBlobs": NetlifyBlobsOptions; "overlay": OverlayOptions; "planetscale": PlanetscaleOptions; + "react-native-async-storage": ReactNativeAsyncStorageOptions; + "reactNativeAsyncStorage": ReactNativeAsyncStorageOptions; "redis": RedisOptions; "s3": S3Options; "session-storage": SessionStorageOptions; @@ -131,6 +134,8 @@ export const builtinDrivers = { "null": "unstorage/drivers/null", "overlay": "unstorage/drivers/overlay", "planetscale": "unstorage/drivers/planetscale", + "react-native-async-storage": "unstorage/drivers/react-native-async-storage", + "reactNativeAsyncStorage": "unstorage/drivers/react-native-async-storage", "redis": "unstorage/drivers/redis", "s3": "unstorage/drivers/s3", "session-storage": "unstorage/drivers/session-storage", @@ -229,6 +234,12 @@ export const builtinDriverDependencies: Partial; +} + +const driver: DriverFactory> = (opts) => { + const base = normalizeKey(opts?.base); + const resolveKey = (key: string) => joinKeys(base, key); + let storage: Promise | undefined; + const getStorage = () => + (storage ??= importLib( + DRIVER_NAME, + "@react-native-async-storage/async-storage", + opts?.lib, + () => import("@react-native-async-storage/async-storage"), + )); + + return { + name: DRIVER_NAME, + options: opts, + getInstance: () => getStorage(), + async hasItem(key) { + return (await (await getStorage()).getItem(resolveKey(key))) !== null; + }, + async getItem(key) { + return (await getStorage()).getItem(resolveKey(key)); + }, + async getItemRaw(key) { + return (await getStorage()).getItem(resolveKey(key)); + }, + async setItem(key, value) { + await (await getStorage()).setItem(resolveKey(key), value); + }, + async setItemRaw(key, value) { + await (await getStorage()).setItem(resolveKey(key), value); + }, + async removeItem(key) { + await (await getStorage()).removeItem(resolveKey(key)); + }, + async getKeys() { + const prefix = base ? `${base}:` : ""; + return (await (await getStorage()).getAllKeys()) + .filter((key) => !prefix || key.startsWith(prefix)) + .map((key) => (prefix ? key.slice(prefix.length) : key)); + }, + async clear(prefix) { + const storage = await getStorage(); + const keyPrefix = resolveKey(prefix || ""); + if (!keyPrefix) { + await storage.clear(); + return; + } + const keys = await storage.getAllKeys(); + await storage.multiRemove(keys.filter((key) => key === keyPrefix || key.startsWith(`${keyPrefix}:`))); + }, + }; +}; + +export default driver; diff --git a/src/react-native-async-storage.d.ts b/src/react-native-async-storage.d.ts new file mode 100644 index 000000000..3e667ba51 --- /dev/null +++ b/src/react-native-async-storage.d.ts @@ -0,0 +1,12 @@ +declare module "@react-native-async-storage/async-storage" { + interface AsyncStorageStatic { + getItem(key: string): Promise; + setItem(key: string, value: string): Promise; + removeItem(key: string): Promise; + getAllKeys(): Promise; + multiRemove(keys: readonly string[]): Promise; + clear(): Promise; + } + const AsyncStorage: AsyncStorageStatic; + export default AsyncStorage; +} diff --git a/test/drivers/react-native-async-storage.test.ts b/test/drivers/react-native-async-storage.test.ts new file mode 100644 index 000000000..a23e60c9a --- /dev/null +++ b/test/drivers/react-native-async-storage.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it, vi } from "vitest"; +import { createStorage } from "../../src/index.ts"; +import { testDriver } from "./utils.ts"; +import driver, { type ReactNativeAsyncStorageOptions } from "../../src/drivers/react-native-async-storage.ts"; + +function createMock() { + const data = new Map(); + const lib = { + getItem: vi.fn(async (key: string) => data.get(key) ?? null), + setItem: vi.fn(async (key: string, value: string) => void data.set(key, value)), + removeItem: vi.fn(async (key: string) => void data.delete(key)), + getAllKeys: vi.fn(async () => [...data.keys()]), + multiRemove: vi.fn(async (keys: readonly string[]) => keys.forEach((key) => data.delete(key))), + clear: vi.fn(async () => void data.clear()), + }; + return lib; +} + +const mock = createMock(); + +function createDriver(base?: string) { + return { driver: driver({ base, lib: mock } as ReactNativeAsyncStorageOptions), lib: mock }; +} + +describe("react-native-async-storage", () => { + testDriver({ driver: () => { + mock.clear(); + return createDriver().driver; + } }); + + it("supports base namespaces", async () => { + const { driver: instance, lib } = createDriver("app"); + const storage = createStorage({ driver: instance }); + await storage.setItem("foo", "bar"); + expect(lib.setItem).toHaveBeenCalledWith("app:foo", '"bar"'); + expect(await storage.getKeys()).toEqual(["foo"]); + await storage.clear(); + expect(lib.clear).toHaveBeenCalled(); + }); +});