From 17bda1eb346516d97b7fdcfe32fd019f64fb8c84 Mon Sep 17 00:00:00 2001 From: Aaron Date: Wed, 24 Jul 2024 23:18:16 -0500 Subject: [PATCH] feat(storage): `init` option and rename `defaultValue` to `fallback` (#827) --- docs/guide/extension-apis/storage.md | 44 +++++++++++-- packages/wxt/src/__tests__/storage.test.ts | 62 ++++++++++++++++--- packages/wxt/src/storage.ts | 72 ++++++++++++++++++---- 3 files changed, 152 insertions(+), 26 deletions(-) diff --git a/docs/guide/extension-apis/storage.md b/docs/guide/extension-apis/storage.md index c6f07b38..9d593074 100644 --- a/docs/guide/extension-apis/storage.md +++ b/docs/guide/extension-apis/storage.md @@ -121,7 +121,7 @@ Storage items contain the same APIs as the `storage` variable, but you can confi const showChangelogOnUpdate = storage.defineItem( 'local:showChangelogOnUpdate', { - defaultValue: true, + fallback: true, }, ); ``` @@ -153,7 +153,7 @@ type IgnoredWebsiteV1 = string; export const ignoredWebsites = storage.defineItem( 'local:ignoredWebsites', { - defaultValue: [], + fallback: [], version: 1, }, ); @@ -173,7 +173,7 @@ export const ignoredWebsites = storage.defineItem( // [!code export const ignoredWebsites = storage.defineItem( // [!code ++] 'local:ignoredWebsites', { - defaultValue: [], + fallback: [], version: 1, // [!code --] version: 2, // [!code ++] migrations: { // [!code ++] @@ -205,7 +205,7 @@ export const ignoredWebsites = storage.defineItem( // [!code export const ignoredWebsites = storage.defineItem( // [!code ++] 'local:ignoredWebsites', { - defaultValue: [], + fallback: [], version: 2, // [!code --] version: 3, // [!code ++] migrations: { @@ -242,7 +242,7 @@ Lets look at the same ignored websites example from before, but start with an un export const ignoredWebsites = storage.defineItem( 'local:ignoredWebsites', { - defaultValue: [], + fallback: [], }, ); ``` @@ -262,7 +262,7 @@ export const ignoredWebsites = storage.defineItem( // [!code --] export const ignoredWebsites = storage.defineItem( // [!code ++] 'local:ignoredWebsites', { - defaultValue: [], + fallback: [], version: 2, // [!code ++] migrations: { // [!code ++] // Ran when migrating from v1 to v2 // [!code ++] @@ -279,3 +279,35 @@ export const ignoredWebsites = storage.defineItem( // [!code ### Running Migrations As soon as `storage.defineItem` is called, WXT checks if migrations need to be ran, and if so, runs them. Calls to get or update the storage item's value or metadata (`getValue`, `setValue`, `removeValue`, `getMeta`, etc) will automatically wait for the migration process to finish before actually reading or writing values. + +### Default Values + +With `storage.defineItem`, there are multiple ways of defining default values: + +1. `fallback` - Return this value from `getValue` instead of `null` if the value is missing. + + This option is great for providing default values for settings: + + ```ts + const theme = storage.defineItem('local:theme', { + fallback: 'dark', + }); + const allowEditing = storage.defineItem('local:allow-editing', { + fallback: true, + }); + ``` + +2. `init` - Initialize and save a value in storage if it is not already saved. + + This is great for values that need to be initialized or set once: + + ```ts + const userId = storage.defineItem('local:user-id', { + init: () => globalThis.crypto.randomUUID(), + }); + const installDate = storage.defineItem('local:install-date', { + init: () => new Date().getTime(), + }); + ``` + + The value is initialized in storage immediately. diff --git a/packages/wxt/src/__tests__/storage.test.ts b/packages/wxt/src/__tests__/storage.test.ts index 6373991d..6d3e838d 100644 --- a/packages/wxt/src/__tests__/storage.test.ts +++ b/packages/wxt/src/__tests__/storage.test.ts @@ -11,6 +11,14 @@ async function waitForMigrations() { return new Promise((res) => setTimeout(res)); } +/** + * Same as `waitForMigrations`, the fake browser being synchronous means the + * `init` logic is finished by the next task in the event queue. + */ +async function waitForInit() { + return new Promise((res) => setTimeout(res)); +} + describe('Storage Utils', () => { beforeEach(() => { fakeBrowser.reset(); @@ -813,20 +821,56 @@ describe('Storage Utils', () => { }); }); - describe('defaultValue', () => { - it('should return the default value when provided', () => { - const defaultValue = 123; - const item = storage.defineItem(`local:test`, { - defaultValue, + describe.each(['fallback', 'defaultValue'] as const)( + '%s option', + (fallbackKey) => { + it('should return the default value when provided', () => { + const fallback = 123; + const item = storage.defineItem(`local:test`, { + [fallbackKey]: fallback, + }); + + expect(item.fallback).toBe(fallback); + expect(item.defaultValue).toBe(fallback); }); - expect(item.defaultValue).toBe(defaultValue); + it('should return null when not provided', () => { + const item = storage.defineItem(`local:test`); + + expect(item.fallback).toBeNull(); + expect(item.defaultValue).toBeNull(); + }); + }, + ); + + describe('init option', () => { + it('should only call init once (per JS context) when calling getValue successively, avoiding race conditions', async () => { + const expected = 1; + const init = vi + .fn() + .mockResolvedValueOnce(expected) + .mockResolvedValue('not' + expected); + const item = storage.defineItem('local:test', { init }); + + await waitForInit(); + + const p1 = item.getValue(); + const p2 = item.getValue(); + + await expect(p1).resolves.toBe(expected); + await expect(p2).resolves.toBe(expected); + + expect(init).toBeCalledTimes(1); }); - it('should return null when not provided', () => { - const item = storage.defineItem(`local:test`); + it('should initialize the value in storage immediately', async () => { + const expected = 1; + const init = vi.fn().mockReturnValue(expected); + storage.defineItem('local:test', { init }); - expect(item.defaultValue).toBeNull(); + await waitForInit(); + + await expect(storage.getItem('local:test')).resolves.toBe(expected); }); }); diff --git a/packages/wxt/src/storage.ts b/packages/wxt/src/storage.ts index 425bfcb3..1450912e 100644 --- a/packages/wxt/src/storage.ts +++ b/packages/wxt/src/storage.ts @@ -9,6 +9,7 @@ import { Storage, browser } from 'wxt/browser'; import { dequal } from 'dequal/lite'; import { logger } from './sandbox/utils/logger'; import { toArray } from './core/utils/arrays'; +import { Mutex } from 'async-mutex'; export const storage = createStorage(); @@ -43,8 +44,8 @@ function createStorage(): WxtStorage { }; }; const getMetaKey = (key: string) => key + '$'; - const getValueOrDefault = (value: any, defaultValue: any) => - value ?? defaultValue ?? null; + const getValueOrFallback = (value: any, fallback: any) => + value ?? fallback ?? null; const getMetaValue = (properties: any) => typeof properties === 'object' && !Array.isArray(properties) ? properties @@ -56,7 +57,7 @@ function createStorage(): WxtStorage { opts: GetItemOptions | undefined, ) => { const res = await driver.getItem(driverKey); - return getValueOrDefault(res, opts?.defaultValue); + return getValueOrFallback(res, opts?.fallback ?? opts?.defaultValue); }; const getMeta = async (driver: WxtStorageDriver, driverKey: string) => { const metaKey = getMetaKey(driverKey); @@ -148,9 +149,10 @@ function createStorage(): WxtStorage { const driverResults = await drivers[driverArea].getItems(keys); return driverResults.map((driverResult) => { const key = `${driverArea}:${driverResult.key}` as StorageItemKey; - const value = getValueOrDefault( + const opts = keyToOptsMap.get(key); + const value = getValueOrFallback( driverResult.value, - keyToOptsMap.get(key)?.defaultValue, + opts?.fallback ?? opts?.defaultValue, ); return { key, value }; }); @@ -302,15 +304,38 @@ function createStorage(): WxtStorage { logger.error(`Migration failed for ${key}`, err); }); - const getDefaultValue = () => opts?.defaultValue ?? null; + const initMutex = new Mutex(); + + const getFallback = () => opts?.fallback ?? opts?.defaultValue ?? null; + + const getOrInitValue = () => + initMutex.runExclusive(async () => { + const value = await driver.getItem(driverKey); + // Don't init value if it already exists or the init function isn't provided + if (value != null || opts?.init == null) return value; + + const newValue = await opts.init(); + await driver.setItem(driverKey, newValue); + return newValue; + }); + + // Initialize the value once migrations have finished + migrationsDone.then(getOrInitValue); return { get defaultValue() { - return getDefaultValue(); + return getFallback(); + }, + get fallback() { + return getFallback(); }, getValue: async () => { await migrationsDone; - return await getItem(driver, driverKey, opts); + if (opts?.init) { + return await getOrInitValue(); + } else { + return await getItem(driver, driverKey, opts); + } }, getMeta: async () => { await migrationsDone; @@ -334,7 +359,7 @@ function createStorage(): WxtStorage { }, watch: (cb) => watch(driver, driverKey, (newValue, oldValue) => - cb(newValue ?? getDefaultValue(), oldValue ?? getDefaultValue()), + cb(newValue ?? getFallback(), oldValue ?? getFallback()), ), migrate, }; @@ -567,7 +592,14 @@ export interface WxtStorageItem< TValue, TMetadata extends Record, > { + /** + * @deprecated Renamed to `fallback`, use it instead. + */ defaultValue: TValue; + /** + * The value provided by the `fallback` option. + */ + fallback: TValue; /** * Get the latest value from storage. */ @@ -610,9 +642,13 @@ export type StorageItemKey = `${StorageArea}:${string}`; export interface GetItemOptions { /** - * Value returned from `getValue` when it would otherwise return null. + * @deprecated Renamed to `fallback`, use it instead. */ defaultValue?: T; + /** + * Default value returned when `getItem` would otherwise return `null`. + */ + fallback?: T; } export interface RemoveItemOptions { @@ -633,7 +669,21 @@ export interface SnapshotOptions { } export interface WxtStorageItemOptions { - defaultValue: T; + /** + * @deprecated Renamed to `fallback`, use it instead. + */ + defaultValue?: T; + /** + * Default value returned when `getValue` would otherwise return `null`. + */ + fallback?: T; + /** + * If passed, a value in storage will be initialized immediately after + * defining the storage item. This function returns the value that will be + * saved to storage during the initialization process if a value doesn't + * already exist. + */ + init?: () => T | Promise; /** * Provide a version number for the storage item to enable migrations. When changing the version * in the future, migration functions will be ran on application startup.