nitropage
Version:
A free and open source, extensible visual page builder based on SolidStart.
317 lines (272 loc) • 7.79 kB
text/typescript
import { cloneDeep, debounce } from "es-toolkit";
import { batch, createEffect, createSignal, onMount } from "solid-js";
import { createStore, reconcile } from "solid-js/store";
export type StorageApi<T = any> = () => Promise<{
store: T;
set: (key: string, value: any) => void | Promise<any>;
get: (key: string) => any;
keys: () => string[] | Promise<string[]>;
remove: (key: string) => void;
serializer?: (v: any) => any;
deserializer?: (v: any) => any;
}>;
const localStorage: StorageApi<globalThis.Storage> = async () => {
const store = globalThis.localStorage;
return {
store,
set(key, value) {
store.setItem(key, value);
},
get(key) {
return store.getItem(key);
},
keys() {
return Object.keys(store);
},
remove(key) {
store.removeItem(key);
},
serializer(v: any) {
return JSON.stringify(v);
},
deserializer(v: any) {
return JSON.parse(v);
},
};
};
export const indexedDbStorage: (args: { name: string }) => StorageApi<any> =
(args) => async () => {
if (typeof window === "undefined") {
return null as any;
}
const { openDB } = await import("idb");
const store = await openDB<any>(args.name, 1, {
upgrade(db) {
db.createObjectStore("kv");
},
});
return {
store,
set(key, value) {
return store.put("kv", value, key);
},
get(key) {
return store.get("kv", key);
},
keys() {
return store.getAllKeys("kv");
},
remove(key) {
return store.delete("kv", key);
},
serializer(v: any) {
// solid unwrap doesnt return a clean enough object :/
const value = cloneDeep(v);
return value;
},
};
};
export const sessionStorage: StorageApi<globalThis.Storage> = async () => {
const store = globalThis.sessionStorage;
return {
store,
set(key, value) {
store.setItem(key, value);
},
get(key) {
return store.getItem(key);
},
keys() {
return Object.keys(store);
},
remove(key) {
store.removeItem(key);
},
serializer(v: any) {
return JSON.stringify(v);
},
deserializer(v: any) {
return JSON.parse(v);
},
};
};
export const createStorage = function <T extends Record<string, any>>(
initState: T,
{
prefix = "app",
timeout = 0,
storageApi = localStorage,
migrate = undefined as
| ((getState: () => Record<string, any>) => boolean)
| undefined,
} = {},
) {
const [state, setState] = createStore(initState);
const [mounted, setMounted] = createSignal(false);
const storePrefix = `${prefix}-`;
const [getStorageApi, setStorageApi] = createSignal<
Awaited<ReturnType<StorageApi>> | undefined
>();
const initApi = async () => {
const api = await storageApi();
const keys = await api.keys();
if (migrate) {
let migratedState: Record<string, any> | undefined = undefined;
const getMigrateState = async () => {
if (migratedState) return migratedState;
migratedState = {};
for (const key of keys) {
if (!key.startsWith(storePrefix)) {
continue;
}
const value = await api.get(key);
migratedState[key.slice(storePrefix.length)] = api.deserializer
? api.deserializer(value)
: value;
}
return migratedState;
};
const migrated = await migrate(getMigrateState);
if (migrated && migratedState != undefined) {
for (const [key, value] of Object.entries(migratedState)) {
await api.set(
`${storePrefix}${key}`,
api.serializer ? api.serializer(value) : value,
);
}
}
migratedState = undefined;
}
setStorageApi(api);
};
if (typeof window !== "undefined") {
initApi();
}
let mounted_ = false;
const updating = {} as Record<string, any>;
const keys = Object.keys(state);
const changedBeforeMount = {} as Record<string, any>;
const [keyStore, setKeyStore] = createStore(
keys.reduce(
function (acc, k) {
acc[k] = {
updateCount: 0,
};
return acc;
},
{} as Record<string, { updateCount: number }>,
),
);
const mountValuePerKey = {} as Record<string, any>;
const mountValuePromises = {} as Record<string, Promise<any>>;
for (const key of keys) {
let storeKey = `${storePrefix}${key}`;
let initRun = true;
// TODO: Implement localStorage listener
// TODO: Clean this up, setState_ is a simpler solution
createEffect(() => {
const api = getStorageApi();
if (!api) {
return;
}
// During mounts we want to always run this effect even if the state value hasnt changed
// We need to run it always to reset updating[key]
keyStore[key].updateCount;
const value = state[key];
(async () => {
if (initRun) {
mountValuePromises[key] = api.get(storeKey);
mountValuePerKey[key] = await mountValuePromises[key];
}
const mountValue = mountValuePerKey[key];
const isInitRun = initRun;
initRun = false;
// const value = serializer(state[key]);
if (isInitRun && mountValue) {
return;
}
// If the key is getting mounted at the moment, we skip the storage set
if (updating[key]) {
updating[key] = false;
return;
}
if (!mounted_) {
changedBeforeMount[key] = true;
}
if (value === undefined) {
api.remove(storeKey);
} else {
api.set(storeKey, api.serializer ? api.serializer(value) : value);
}
})();
});
}
const timeoutByKey: Record<string, any> = {};
const updateDebouncedByKey = function (key: string) {
if (timeoutByKey[key]) {
clearTimeout(timeoutByKey[key]);
}
timeoutByKey[key] = setTimeout(function () {
requestAnimationFrame(function () {
const api = getStorageApi();
if (!api) {
return;
}
const storeKey = `${storePrefix}${key}`;
const value = state[key];
if (value === undefined) {
api.remove(storeKey);
} else {
api.set(storeKey, api.serializer ? api.serializer(value) : value);
}
});
}, timeout);
};
const updateAllKeys = debounce(function () {
console.info("reconcile, save all storage keys");
keys.forEach(updateDebouncedByKey);
}, timeout);
const setState_ = function (...args: any) {
const result = (setState as any)(...args);
if (typeof args[0] === "string") {
updateDebouncedByKey(args[0]);
} else {
updateAllKeys();
}
return result;
};
onMount(() => {
createEffect(() => {
const api = getStorageApi();
if (!api) {
return;
}
(async () => {
for (const key of keys) {
const mountValue = await mountValuePromises[key];
if (!changedBeforeMount[key] && mountValue) {
updating[key] = true;
batch(function () {
setKeyStore(key, "updateCount", (v) => v + 1);
//setUpdatingCount(updatingCount() + 1);
setState(
key as any,
reconcile(
api.deserializer ? api.deserializer(mountValue) : mountValue,
),
);
});
}
mountValuePerKey[key] = null;
}
setMounted(true);
mounted_ = true;
})();
});
});
return [state, setState_, mounted] as [
typeof state,
typeof setState,
typeof mounted,
];
};