Instantly share code, notes, and snippets.
Created
October 24, 2024 15:57
-
Star
(0)
0
You must be signed in to star a gist -
Fork
(0)
0
You must be signed in to fork a gist
-
Save webstrand/2ced57befd922411f78d3669a889f7b4 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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