Skip to content

Instantly share code, notes, and snippets.

@amal
Last active September 29, 2024 20:49
Show Gist options
  • Save amal/1605ba93651f9d2e0a2efd2ea44192df to your computer and use it in GitHub Desktop.
Save amal/1605ba93651f9d2e0a2efd2ea44192df to your computer and use it in GitHub Desktop.
Reatom LazyStoreItem for WebExt
/**
* Returns `false` if the value is `null`, `undefined`, `NaN`, or `Infinity`.
*/
export const isV = <T>(v: T | undefined | null | typeof NaN | typeof Infinity): v is T =>
v !== null && v !== undefined && !Number.isNaN(v) && v !== Infinity && v !== -Infinity;
import type {Ctx, LensAtom} from "@reatom/framework";
/**
* Read-only Atom with a `set` method to update the value.
*/
export interface RItemAtom<TValue = unknown> extends LensAtom<TValue> {
readonly set: (ctx: Ctx, value: TValue) => any
}
import {StorageItemKey, WxtStorageItem } from 'wxt/storage';
export type LazyStoreItem<TValue, TMetadata extends Record<string, unknown> = {}> =
(() => WxtStorageItem<TValue, TMetadata>)
& { key?: StorageItemKey, fallback?: TValue };
import type {LazyStoreItem} from "./storage.ts";
import type {RItemAtom} from "./RItemAtom.ts";
import {
atom,
Ctx,
isInit,
onConnect,
onDisconnect,
readonly,
reatomAsync,
withAssign,
withDataAtom
} from "@reatom/framework";
const _DEBUG = false;
const resolve = Promise.resolve;
/**
* A real update of the storage should be throttled.
*
* MAX_WRITE_OPERATIONS_PER_MINUTE is 120 write ops per minute.
* An op every 8-9 ms is allowed.
* But we want to allow concurrent store items, so let's take 50 ms.
*/
const OP_THROTTLE_MS = 50;
// TODO: Allow readonly storeItemReatom?
/**
* Returns an atom that wraps a value in the storage.
*
* @param lazyItem A function that returns a storage item.
* @param fallback The fallback/default value.
* @param filter An optional function that filters or maps the value from the storage, sync or async.
* @param encDec An optional object with the encoder and decoder functions.
*/
export function storeItemReatom<
// Force TValue to be a non-nullable non-undefined type.
TValue extends unknown,
TFilteredValue extends TValue = TValue,
StoreValue = TValue,
>(
lazyItem: LazyStoreItem<StoreValue>,
fallback: TFilteredValue = lazyItem.fallback! as TFilteredValue,
filter?: (value?: TValue | TFilteredValue) => Promise<TFilteredValue | undefined> | TFilteredValue | undefined,
encDec?: {
enc: (value: TFilteredValue) => Promise<StoreValue>,
dec: (value: StoreValue) => Promise<TValue>,
},
): RItemAtom<TFilteredValue> {
let key: string | undefined;
if (DEV) {
key = lazyItem.key;
key = key ? `${key}::` : '';
}
const filtered: (v?: TValue | TFilteredValue) => Promise<TFilteredValue | undefined> =
filter ? (v) => resolve(filter(v)) : resolve;
const atomAwareFilter = async (value?: TValue | TFilteredValue, ctx?: Ctx): Promise<TFilteredValue> => {
let v: TFilteredValue | undefined = await filtered(value);
if (!isV(v) && v !== null) {
if (ctx) {
v = ctx.get(dataAtom);
if (!isV(v) && v !== null) {
v = fallback
}
} else {
v = fallback
}
}
return v;
};
/**
* Basic async + dataAtom.
*
* @TODO: Can it be simplified with reatomResource?
*/
const loader = reatomAsync(
async (ctx) => {
const storeValue: StoreValue = await lazyItem().getValue();
const rawValue = encDec
? await encDec.dec(storeValue)
: storeValue as unknown as TValue;
return atomAwareFilter(rawValue, ctx);
},
rn(`${key}Loader`),
).pipe(withDataAtom(fallback));
const dataAtom = loader.dataAtom;
if (DEV && _DEBUG) {
loader.onChange((_ctx, v) => {
console.debug(`${key}loader onChange`, v);
});
dataAtom.onChange((_ctx, v) => {
console.debug(`${key}dataAtom onChange`, v);
});
}
// Watch for sync storage changes
// and update the data atom accordingly.
onConnect(dataAtom, (ctx) => {
if (DEV && _DEBUG) console.debug(`on ${key}dataAtom connect`);
if (isInit(ctx)) {
if (DEV && _DEBUG) console.debug(`on ${key}dataAtom connect, isInit, calling loader`);
loader(ctx);
}
return lazyItem().watch(async (storeValue, oldValue) => {
const rawValue = encDec
? await encDec.dec(storeValue)
: storeValue as unknown as TValue;
const v = await filtered(rawValue);
if (DEV && _DEBUG) {
console.debug(`on ${key}dataAtom change from store (was: ${oldValue}):`, storeValue, v);
}
if (isV(v) || v === null) {
dataAtom(ctx, v);
} else if (DEV && _DEBUG) {
console.warn(`Invalid ${key} value from store. was: ${oldValue}, new:`, storeValue);
}
})
});
if (DEV && _DEBUG) onDisconnect(dataAtom, () => console.debug(`on ${key}dataAtom disconnect`));
let timer: any;
const updater = reatomAsync(
async (ctx, value: TFilteredValue | TValue) => {
const v: TFilteredValue | undefined = await filtered(value);
if (DEV && _DEBUG) console.debug(`${key}updater`, value, v, isV(v) || v === null);
if (isV(v) || v === null) {
// An optimistic update of the data atom.
dataAtom(ctx, v);
if (DEV && _DEBUG) console.debug(`${key}updater optimistic done`, value, v);
clearTimeout(timer);
timer = setTimeout(async () => {
const item = lazyItem();
await (v !== null
? item.setValue(encDec ? await encDec.enc(v) : v as unknown as StoreValue)
: item.removeValue({removeMeta: true}));
if (DEV && _DEBUG) console.debug(`${key}updater done`, value, v);
}, OP_THROTTLE_MS);
}
},
rn(`${key}updater`),
// 1. onEffect is called BEFORE the effect is executed.
// 2. Effect is executed.
// 3. onFulfill is called AFTER the effect is fulfilled.
// 4. onSettle is called AFTER the effect is settled.
);
return atom((ctx) => {
// FIXME: Somehow the onConnect is not called sometimes.
if (isInit(ctx)) {
if (DEV && _DEBUG) console.debug(`on ${key}proxy init, calling loader`);
loader(ctx);
}
return ctx.spy(dataAtom);
}, rn(`${key}proxy`)).pipe(
readonly,
withAssign(() => ({
set: updater,
})),
);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment