feat(storage): init option and rename defaultValue to fallback (#827)
This commit is contained in:
@@ -121,7 +121,7 @@ Storage items contain the same APIs as the `storage` variable, but you can confi
|
||||
const showChangelogOnUpdate = storage.defineItem<boolean>(
|
||||
'local:showChangelogOnUpdate',
|
||||
{
|
||||
defaultValue: true,
|
||||
fallback: true,
|
||||
},
|
||||
);
|
||||
```
|
||||
@@ -153,7 +153,7 @@ type IgnoredWebsiteV1 = string;
|
||||
export const ignoredWebsites = storage.defineItem<IgnoredWebsiteV1[]>(
|
||||
'local:ignoredWebsites',
|
||||
{
|
||||
defaultValue: [],
|
||||
fallback: [],
|
||||
version: 1,
|
||||
},
|
||||
);
|
||||
@@ -173,7 +173,7 @@ export const ignoredWebsites = storage.defineItem<IgnoredWebsiteV1[]>( // [!code
|
||||
export const ignoredWebsites = storage.defineItem<IgnoredWebsiteV2[]>( // [!code ++]
|
||||
'local:ignoredWebsites',
|
||||
{
|
||||
defaultValue: [],
|
||||
fallback: [],
|
||||
version: 1, // [!code --]
|
||||
version: 2, // [!code ++]
|
||||
migrations: { // [!code ++]
|
||||
@@ -205,7 +205,7 @@ export const ignoredWebsites = storage.defineItem<IgnoredWebsiteV2[]>( // [!code
|
||||
export const ignoredWebsites = storage.defineItem<IgnoredWebsiteV3[]>( // [!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<string[]>(
|
||||
'local:ignoredWebsites',
|
||||
{
|
||||
defaultValue: [],
|
||||
fallback: [],
|
||||
},
|
||||
);
|
||||
```
|
||||
@@ -262,7 +262,7 @@ export const ignoredWebsites = storage.defineItem<string[]>( // [!code --]
|
||||
export const ignoredWebsites = storage.defineItem<IgnoredWebsiteV2[]>( // [!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<IgnoredWebsiteV2[]>( // [!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.
|
||||
|
||||
@@ -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<number>(`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<number>(`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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+61
-11
@@ -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<any> | undefined,
|
||||
) => {
|
||||
const res = await driver.getItem<any>(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<any>(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<any>(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<string, unknown>,
|
||||
> {
|
||||
/**
|
||||
* @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<T> {
|
||||
/**
|
||||
* 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<T> {
|
||||
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<T>;
|
||||
/**
|
||||
* 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.
|
||||
|
||||
Reference in New Issue
Block a user