feat(storage): init option and rename defaultValue to fallback (#827)

This commit is contained in:
Aaron
2024-07-24 23:18:16 -05:00
committed by GitHub
parent 96e28c415c
commit 17bda1eb34
3 changed files with 152 additions and 26 deletions
+38 -6
View File
@@ -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.
+53 -9
View File
@@ -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
View File
@@ -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.