Skip to content

Instantly share code, notes, and snippets.

@webstrand
Created October 24, 2024 15:57
Show Gist options
  • Save webstrand/2ced57befd922411f78d3669a889f7b4 to your computer and use it in GitHub Desktop.
Save webstrand/2ced57befd922411f78d3669a889f7b4 to your computer and use it in GitHub Desktop.
export class SharedMap<K extends string, V> extends Map<string, V> {
storageKey: string;
subscriptions = new Set<(added: Iterable<string>, deleted: Iterable<string>) => void>;
updatedKeys!: Set<string>;
deletedKeys!: Set<string>;
timeoutId = 0;
validateValue;
deserializeValue;
serializeValue;
storage;
constructor(storageKey: string, validator: (x: unknown, key: string) => x is V, deserialize: (x: string) => unknown = JSON.parse, serialize: (x: V) => string = JSON.stringify, storage: Storage = localStorage) {
super();
this.storageKey = storageKey;
this.validateValue = validator;
this.deserializeValue = deserialize;
this.serializeValue = serialize;
this.storage = storage;
this.updateFromStorage();
window.addEventListener("storage", this.handleStorageEvent);
}
queueEmit(updatedKeys: Iterable<string> | null, deletedKeys: Iterable<string> | null) {
console.log("queued emit!");
if(this.timeoutId === 0) {
this.updatedKeys = new Set(updatedKeys);
this.deletedKeys = new Set(deletedKeys);
}
else {
if(updatedKeys) for(const key of updatedKeys) this.updatedKeys.add(key);
if(deletedKeys) for(const key of deletedKeys) this.deletedKeys.add(key);
}
// We use setTimeout to enqueue a macrotask, hopefully after any other queued storage events have been handled.
clearTimeout(this.timeoutId);
this.timeoutId = setTimeout(this.emit, 0);
}
emit = () => {
clearTimeout(this.timeoutId);
this.timeoutId = 0;
const updatedKeys = Object.freeze(this.updatedKeys);
const deletedKeys = Object.freeze(this.deletedKeys);
for(const subscription of this.subscriptions) subscription(updatedKeys, deletedKeys);
}
handleStorageEvent = (event: StorageEvent) => {
if (event.key === null) {
this.updateFrom([]);
}
else if (event.key === this.storageKey) {
this.updateFrom(this.deserializeKeys(event.newValue));
}
else {
const subkey = this.extractSubkey(event.key);
if (subkey) {
if(!this.has(subkey)) {
if(event.newValue !== null) console.warn(`Saw update for unknown ${subkey}: ${event.newValue}`);
}
if (event.newValue === null || event.newValue === "") console.warn(`Storage event tried to update ${subkey}, but value was invalid`);
this.updateKeyFrom(subkey, event.newValue ?? "null");
}
}
}
set(key: K, value: V): this {
const knownKey = this.has(key);
super.set(key, value);
this.storage.setItem(this.createSubkey(key), this.serializeValue(value));
if (!knownKey) this.storage.setItem(this.storageKey, this.serializeKeys([...this.keys()]));
this.queueEmit([key], null);
return this;
}
delete(key: K): boolean {
const result = super.delete(key);
if (result) {
this.storage.setItem(this.storageKey, this.serializeKeys([...this.keys()]));
this.storage.removeItem(this.createSubkey(key));
this.queueEmit(null, [key]);
}
return result;
}
clear(): void {
this.storage.setItem(this.storageKey, "[]");
for(const key of this.keys()) {
this.storage.removeItem(this.createSubkey(key));
}
this.queueEmit(null, [...this.keys()]);
super.clear();
}
createSubkey(key: string): string {
return `${this.storageKey}.${key}`;
}
extractSubkey(input: string): string | null {
if (input.lastIndexOf(this.storageKey, 0) !== 0) return null;
return input.slice(this.storageKey.length + 1);
}
/** Given a list of keys, diff and update */
updateFrom(keys: string[]) {
const modifiedKeysSet = new Set(keys);
const deletedKeys = [...this.keys()].filter((key) => !modifiedKeysSet.has(key));
const addedKeys = keys.filter((key) => !this.has(key));
for (const key of deletedKeys) {
super.delete(key);
}
for (const key of addedKeys) {
this.updateKeyFromStorage(key);
}
this.queueEmit([], deletedKeys);
}
/** Get a list of keys from the Storage, diff and update */
updateFromStorage() {
this.updateFrom(this.deserializeKeys(this.storage.getItem(this.storageKey)));
}
/** Update a key with a given serialized value */
updateKeyFrom(key: string, serializedValue: string) {
const value = this.deserializeValue(serializedValue);
if(this.validateValue(value, key)) {
super.set(key, value);
this.queueEmit([key], []);
}
else {
console.warn("unable to validate deserialized value for ", key, serializedValue, value);
}
}
/** Get a key from the Storage and update it locally */
updateKeyFromStorage(key: string) {
const value = localStorage.getItem(this.createSubkey(key));
if(value === null || value === "") console.warn(`Tried to update ${key} from local storage, but value was invalid`);
this.updateKeyFrom(key, this.storage.getItem(this.createSubkey(key)) ?? "null");
}
/** Given a list of keys serialized for localStorage, attempt to deserialize it */
deserializeKeys(serializedKeys: string | null | undefined): string[] {
try {
const keys = JSON.parse(serializedKeys ?? "[]");
if (!Array.isArray(keys)) return [];
return keys.filter((value): value is string => typeof value === "string")
}
catch(e: unknown) {
console.warn("Tried to deserialize keys", serializedKeys, e);
return [];
}
}
/** Serialize a list of keys for localStorage */
serializeKeys(keys: string[]) {
return JSON.stringify(keys);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment