From c3e1a0bb519bf5012781891c15c433841203bce2 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Fri, 14 Apr 2023 13:07:23 -0300 Subject: observable memory impl --- packages/web-util/src/context/translation.ts | 6 +- packages/web-util/src/hooks/index.ts | 2 +- packages/web-util/src/hooks/useLang.ts | 9 +- packages/web-util/src/hooks/useLocalStorage.ts | 105 +++++--------- packages/web-util/src/index.browser.ts | 1 + packages/web-util/src/utils/observable.ts | 181 +++++++++++++++++++++++++ 6 files changed, 223 insertions(+), 81 deletions(-) create mode 100644 packages/web-util/src/utils/observable.ts (limited to 'packages') diff --git a/packages/web-util/src/context/translation.ts b/packages/web-util/src/context/translation.ts index 3b79e31d3..53ca87f9d 100644 --- a/packages/web-util/src/context/translation.ts +++ b/packages/web-util/src/context/translation.ts @@ -26,7 +26,6 @@ interface Type { supportedLang: { [id in keyof typeof supportedLang]: string }; changeLanguage: (l: string) => void; i18n: InternationalizationAPI; - isSaved: boolean; } const supportedLang = { @@ -46,7 +45,6 @@ const initial = { // do not change anything }, i18n, - isSaved: false, }; const Context = createContext(initial); @@ -64,7 +62,7 @@ export const TranslationProvider = ({ forceLang, source, }: Props): VNode => { - const [lang, changeLanguage, isSaved] = useLang(initial); + const { value: lang, update: changeLanguage } = useLang(initial); useEffect(() => { if (forceLang) { changeLanguage(forceLang); @@ -80,7 +78,7 @@ export const TranslationProvider = ({ } return h(Context.Provider, { - value: { lang, changeLanguage, supportedLang, i18n, isSaved }, + value: { lang, changeLanguage, supportedLang, i18n }, children, }); }; diff --git a/packages/web-util/src/hooks/index.ts b/packages/web-util/src/hooks/index.ts index 393a6fcbb..e5cb54e21 100644 --- a/packages/web-util/src/hooks/index.ts +++ b/packages/web-util/src/hooks/index.ts @@ -1,5 +1,5 @@ export { useLang } from "./useLang.js"; -export { useLocalStorage, useNotNullLocalStorage } from "./useLocalStorage.js"; +export { useLocalStorage } from "./useLocalStorage.js"; export { useAsyncAsHook, HookError, diff --git a/packages/web-util/src/hooks/useLang.ts b/packages/web-util/src/hooks/useLang.ts index 5b02c5255..9888cc51a 100644 --- a/packages/web-util/src/hooks/useLang.ts +++ b/packages/web-util/src/hooks/useLang.ts @@ -14,17 +14,16 @@ GNU Anastasis; see the file COPYING. If not, see */ -import { useNotNullLocalStorage } from "./useLocalStorage.js"; +import { LocalStorageState, useLocalStorage } from "./useLocalStorage.js"; function getBrowserLang(): string | undefined { + if (typeof window === "undefined") return undefined; if (window.navigator.languages) return window.navigator.languages[0]; if (window.navigator.language) return window.navigator.language; return undefined; } -export function useLang( - initial?: string, -): [string, (s: string) => void, boolean] { +export function useLang(initial?: string): Required { const defaultLang = (getBrowserLang() || initial || "en").substring(0, 2); - return useNotNullLocalStorage("lang-preference", defaultLang); + return useLocalStorage("lang-preference", defaultLang); } diff --git a/packages/web-util/src/hooks/useLocalStorage.ts b/packages/web-util/src/hooks/useLocalStorage.ts index ab786db13..264919d37 100644 --- a/packages/web-util/src/hooks/useLocalStorage.ts +++ b/packages/web-util/src/hooks/useLocalStorage.ts @@ -19,92 +19,55 @@ * @author Sebastian Javier Marchano (sebasjm) */ -import { StateUpdater, useEffect, useState } from "preact/hooks"; +import { useEffect, useState } from "preact/hooks"; +import { localStorageMap, memoryMap } from "../utils/observable.js"; +export interface LocalStorageState { + value?: string; + update: (s: string) => void; + reset: () => void; +} + +const supportLocalStorage = typeof window !== "undefined"; + +const storage = supportLocalStorage ? localStorageMap() : memoryMap(); + +export function useLocalStorage( + key: string, + initialValue: string, +): Required; +export function useLocalStorage(key: string): LocalStorageState; export function useLocalStorage( key: string, initialValue?: string, -): [string | undefined, StateUpdater] { +): LocalStorageState { const [storedValue, setStoredValue] = useState( (): string | undefined => { - return typeof window !== "undefined" - ? window.localStorage.getItem(key) || initialValue - : initialValue; + return storage.get(key) ?? initialValue; }, ); useEffect(() => { - const listener = buildListenerForKey(key, (newValue) => { + return storage.onUpdate(key, () => { + const newValue = storage.get(key); + console.log("new value", key, newValue); setStoredValue(newValue ?? initialValue); }); - window.addEventListener("storage", listener); - return () => { - window.removeEventListener("storage", listener); - }; }, []); - const setValue = ( - value?: string | ((val?: string) => string | undefined), - ): void => { - setStoredValue((p) => { - const toStore = value instanceof Function ? value(p) : value; - if (typeof window !== "undefined") { - if (!toStore) { - window.localStorage.removeItem(key); - } else { - window.localStorage.setItem(key, toStore); - } - } - return toStore; - }); - }; - - return [storedValue, setValue]; -} - -function buildListenerForKey( - key: string, - onUpdate: (newValue: string | undefined) => void, -): () => void { - return function listenKeyChange() { - const value = window.localStorage.getItem(key); - onUpdate(value ?? undefined); - }; -} - -//TODO: merge with the above function -export function useNotNullLocalStorage( - key: string, - initialValue: string, -): [string, StateUpdater, boolean] { - const [storedValue, setStoredValue] = useState((): string => { - return typeof window !== "undefined" - ? window.localStorage.getItem(key) || initialValue - : initialValue; - }); - - useEffect(() => { - const listener = buildListenerForKey(key, (newValue) => { - setStoredValue(newValue ?? initialValue); - }); - window.addEventListener("storage", listener); - return () => { - window.removeEventListener("storage", listener); - }; - }); - - const setValue = (value: string | ((val: string) => string)): void => { - const valueToStore = value instanceof Function ? value(storedValue) : value; - setStoredValue(valueToStore); - if (typeof window !== "undefined") { - if (!valueToStore) { - window.localStorage.removeItem(key); - } else { - window.localStorage.setItem(key, valueToStore); - } + const setValue = (value?: string): void => { + if (!value) { + storage.delete(key); + } else { + storage.set(key, value); } }; - const isSaved = window.localStorage.getItem(key) !== null; - return [storedValue, setValue, isSaved]; + return { + value: storedValue, + update: setValue, + reset: () => { + setValue(initialValue); + }, + }; } diff --git a/packages/web-util/src/index.browser.ts b/packages/web-util/src/index.browser.ts index 2ae3f2a0b..b1df2f96e 100644 --- a/packages/web-util/src/index.browser.ts +++ b/packages/web-util/src/index.browser.ts @@ -1,5 +1,6 @@ export * from "./hooks/index.js"; export * from "./utils/request.js"; +export * from "./utils/observable.js"; export * from "./context/index.js"; export * from "./components/index.js"; export * as tests from "./tests/index.js"; diff --git a/packages/web-util/src/utils/observable.ts b/packages/web-util/src/utils/observable.ts new file mode 100644 index 000000000..dfa434635 --- /dev/null +++ b/packages/web-util/src/utils/observable.ts @@ -0,0 +1,181 @@ +export type ObservableMap = Map & { + onUpdate: (key: string, callback: () => void) => () => void; +}; + +const UPDATE_EVENT_NAME = "update"; + +//FIXME: allow different type for different properties +export function memoryMap(): ObservableMap { + const obs = new EventTarget(); + const theMap = new Map(); + const theMemoryMap: ObservableMap = { + onUpdate: (key, handler) => { + //@ts-ignore + theMemoryMap.size = theMap.length; + obs.addEventListener(`update-${key}`, handler); + obs.addEventListener(`clear`, handler); + return () => { + obs.removeEventListener(`update-${key}`, handler); + obs.removeEventListener(`clear`, handler); + }; + }, + delete: (key: string) => { + const result = theMap.delete(key); + obs.dispatchEvent(new Event(`update-${key}`)); + return result; + }, + set: (key: string, value: T) => { + theMap.set(key, value); + obs.dispatchEvent(new Event(`update-${key}`)); + return theMemoryMap; + }, + clear: () => { + theMap.clear(); + obs.dispatchEvent(new Event(`clear`)); + }, + entries: theMap.entries.bind(theMap), + forEach: theMap.forEach.bind(theMap), + get: theMap.get.bind(theMap), + has: theMap.has.bind(theMap), + keys: theMap.keys.bind(theMap), + size: theMap.size, + values: theMap.values.bind(theMap), + [Symbol.iterator]: theMap[Symbol.iterator], + [Symbol.toStringTag]: "theMemoryMap", + }; + return theMemoryMap; +} + +export function localStorageMap(): ObservableMap { + const obs = new EventTarget(); + const theLocalStorageMap: ObservableMap = { + onUpdate: (key, handler) => { + //@ts-ignore + theLocalStorageMap.size = localStorage.length; + obs.addEventListener(`update-${key}`, handler); + obs.addEventListener(`clear`, handler); + function handleStorageEvent(ev: StorageEvent) { + if (ev.key === null || ev.key === key) { + handler(); + } + } + window.addEventListener("storage", handleStorageEvent); + return () => { + window.removeEventListener("storage", handleStorageEvent); + obs.removeEventListener(`update-${key}`, handler); + obs.removeEventListener(`clear`, handler); + }; + }, + delete: (key: string) => { + const exists = localStorage.getItem(key) !== null; + localStorage.removeItem(key); + obs.dispatchEvent(new Event(`update-${key}`)); + return exists; + }, + set: (key: string, v: string) => { + localStorage.setItem(key, v); + obs.dispatchEvent(new Event(`update-${key}`)); + return theLocalStorageMap; + }, + clear: () => { + localStorage.clear(); + obs.dispatchEvent(new Event(`clear`)); + }, + entries: (): IterableIterator<[string, string]> => { + let index = 0; + const total = localStorage.length; + return { + next() { + const key = localStorage.key(index); + if (key === null) { + //we are going from 0 until last, this should not happen + throw Error("key cant be null"); + } + const item = localStorage.getItem(key); + if (item === null) { + //the key exist, this should not happen + throw Error("value cant be null"); + } + if (index == total) return { done: true, value: [key, item] }; + index = index + 1; + return { done: false, value: [key, item] }; + }, + [Symbol.iterator]() { + return this; + }, + }; + }, + forEach: (cb) => { + for (let index = 0; index < localStorage.length; index++) { + const key = localStorage.key(index); + if (key === null) { + //we are going from 0 until last, this should not happen + throw Error("key cant be null"); + } + const item = localStorage.getItem(key); + if (item === null) { + //the key exist, this should not happen + throw Error("value cant be null"); + } + cb(key, item, theLocalStorageMap); + } + }, + get: (key: string) => { + const item = localStorage.getItem(key); + if (item === null) return undefined; + return item; + }, + has: (key: string) => { + return localStorage.getItem(key) === null; + }, + keys: () => { + let index = 0; + const total = localStorage.length; + return { + next() { + const key = localStorage.key(index); + if (key === null) { + //we are going from 0 until last, this should not happen + throw Error("key cant be null"); + } + if (index == total) return { done: true, value: key }; + index = index + 1; + return { done: false, value: key }; + }, + [Symbol.iterator]() { + return this; + }, + }; + }, + size: localStorage.length, + values: () => { + let index = 0; + const total = localStorage.length; + return { + next() { + const key = localStorage.key(index); + if (key === null) { + //we are going from 0 until last, this should not happen + throw Error("key cant be null"); + } + const item = localStorage.getItem(key); + if (item === null) { + //the key exist, this should not happen + throw Error("value cant be null"); + } + if (index == total) return { done: true, value: item }; + index = index + 1; + return { done: false, value: item }; + }, + [Symbol.iterator]() { + return this; + }, + }; + }, + [Symbol.iterator]: function (): IterableIterator<[string, string]> { + return theLocalStorageMap.entries(); + }, + [Symbol.toStringTag]: "theLocalStorageMap", + }; + return theLocalStorageMap; +} -- cgit v1.2.3