From baad87379465e66fa62ae55bca90605f1aa3dddf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rge=20N=C3=A6ss?= Date: Fri, 27 Sep 2024 14:43:16 +0200 Subject: [PATCH] refactor(store) simplify key value store, add SWR through local storage behavior --- .../src/core/store/key-value/KeyValueStore.ts | 52 ---------------- .../store/key-value/backends/localStorage.ts | 53 ---------------- .../core/store/key-value/backends/memory.ts | 26 -------- .../core/store/key-value/backends/types.ts | 15 ----- .../sanity/src/core/store/key-value/index.ts | 2 +- .../src/core/store/key-value/keyValueStore.ts | 9 +++ .../core/store/key-value/localStorageSWR.ts | 39 ++++++++++++ .../store/key-value/serverKeyValueStore.ts | 49 +++++++++++++++ .../key-value/storage/localStoreStorage.ts | 38 ++++++++++++ .../store/key-value/storage/memoryStorage.ts | 13 ++++ .../server.ts => storage/serverStorage.ts} | 62 ++++++++----------- .../src/core/store/key-value/storage/types.ts | 6 ++ .../sanity/src/core/store/key-value/types.ts | 2 +- .../src/structure/useStructureToolSetting.ts | 24 ++----- 14 files changed, 186 insertions(+), 204 deletions(-) delete mode 100644 packages/sanity/src/core/store/key-value/KeyValueStore.ts delete mode 100644 packages/sanity/src/core/store/key-value/backends/localStorage.ts delete mode 100644 packages/sanity/src/core/store/key-value/backends/memory.ts delete mode 100644 packages/sanity/src/core/store/key-value/backends/types.ts create mode 100644 packages/sanity/src/core/store/key-value/keyValueStore.ts create mode 100644 packages/sanity/src/core/store/key-value/localStorageSWR.ts create mode 100644 packages/sanity/src/core/store/key-value/serverKeyValueStore.ts create mode 100644 packages/sanity/src/core/store/key-value/storage/localStoreStorage.ts create mode 100644 packages/sanity/src/core/store/key-value/storage/memoryStorage.ts rename packages/sanity/src/core/store/key-value/{backends/server.ts => storage/serverStorage.ts} (52%) create mode 100644 packages/sanity/src/core/store/key-value/storage/types.ts diff --git a/packages/sanity/src/core/store/key-value/KeyValueStore.ts b/packages/sanity/src/core/store/key-value/KeyValueStore.ts deleted file mode 100644 index d0e95c540e0..00000000000 --- a/packages/sanity/src/core/store/key-value/KeyValueStore.ts +++ /dev/null @@ -1,52 +0,0 @@ -import {type SanityClient} from '@sanity/client' -import {merge, type Observable, Subject} from 'rxjs' -import {filter, map, shareReplay, switchMap, take} from 'rxjs/operators' - -import {serverBackend} from './backends/server' -import {type KeyValueStore, type KeyValueStoreValue} from './types' - -/** @internal */ -export function createKeyValueStore({client}: {client: SanityClient}): KeyValueStore { - const storageBackend = serverBackend({client}) - - const setKey$ = new Subject<{key: string; value: KeyValueStoreValue}>() - - const updates$ = setKey$.pipe( - switchMap((event) => { - return storageBackend.setKey(event.key, event.value).pipe( - map((nextValue) => ({ - key: event.key, - value: nextValue, - })), - ) - }), - shareReplay(1), - ) - - const getKey = (key: string): Observable => { - return merge( - storageBackend.getKey(key), - updates$.pipe( - filter((update) => update.key === key), - map((update) => update.value), - ), - ) as Observable - } - - const setKey = (key: string, value: KeyValueStoreValue): Observable => { - setKey$.next({key, value}) - - /* - * The backend returns the result of the set operation, so we can just pass that along. - * Most utils do not use it (they will take advantage of local state first) but it reflects the - * backend function and could be useful for debugging. - */ - return updates$.pipe( - filter((update) => update.key === key), - map((update) => update.value as KeyValueStoreValue), - take(1), - ) - } - - return {getKey, setKey} -} diff --git a/packages/sanity/src/core/store/key-value/backends/localStorage.ts b/packages/sanity/src/core/store/key-value/backends/localStorage.ts deleted file mode 100644 index 860a7c6156a..00000000000 --- a/packages/sanity/src/core/store/key-value/backends/localStorage.ts +++ /dev/null @@ -1,53 +0,0 @@ -import {type Observable, of as observableOf} from 'rxjs' - -import {type Backend, type KeyValuePair} from './types' - -const tryParse = (val: string) => { - try { - return JSON.parse(val) - } catch (err) { - // eslint-disable-next-line no-console - console.warn(`Failed to parse settings: ${err.message}`) - return null - } -} - -const getKey = (key: string): Observable => { - const val = localStorage.getItem(key) - - return observableOf(val === null ? null : tryParse(val)) -} - -const setKey = (key: string, nextValue: unknown): Observable => { - // Can't stringify undefined, and nulls are what - // `getItem` returns when key does not exist - if (typeof nextValue === 'undefined' || nextValue === null) { - localStorage.removeItem(key) - } else { - localStorage.setItem(key, JSON.stringify(nextValue)) - } - - return observableOf(nextValue) -} - -const getKeys = (keys: string[]): Observable => { - const values = keys.map((key, i) => { - const val = localStorage.getItem(key) - return val === null ? null : tryParse(val) - }) - - return observableOf(values) -} - -const setKeys = (keyValuePairs: KeyValuePair[]): Observable => { - keyValuePairs.forEach((pair) => { - if (pair.value === undefined || pair.value === null) { - localStorage.removeItem(pair.key) - } else { - localStorage.setItem(pair.key, JSON.stringify(pair.value)) - } - }) - return observableOf(keyValuePairs.map((pair) => pair.value)) -} - -export const localStorageBackend: Backend = {getKey, setKey, getKeys, setKeys} diff --git a/packages/sanity/src/core/store/key-value/backends/memory.ts b/packages/sanity/src/core/store/key-value/backends/memory.ts deleted file mode 100644 index 3331ffd94ca..00000000000 --- a/packages/sanity/src/core/store/key-value/backends/memory.ts +++ /dev/null @@ -1,26 +0,0 @@ -import {type Observable, of as observableOf} from 'rxjs' - -import {type Backend, type KeyValuePair} from './types' - -const DB = Object.create(null) - -const getKey = (key: string): Observable => observableOf(key in DB ? DB[key] : null) - -const setKey = (key: string, nextValue: unknown): Observable => { - DB[key] = nextValue - return observableOf(nextValue) -} - -const getKeys = (keys: string[]): Observable => { - return observableOf(keys.map((key, i) => (key in DB ? DB[key] : null))) -} - -const setKeys = (keyValuePairs: KeyValuePair[]): Observable => { - keyValuePairs.forEach((pair) => { - DB[pair.key] = pair.value - }) - - return observableOf(keyValuePairs.map((pair) => pair.value)) -} - -export const memoryBackend: Backend = {getKey, setKey, getKeys, setKeys} diff --git a/packages/sanity/src/core/store/key-value/backends/types.ts b/packages/sanity/src/core/store/key-value/backends/types.ts deleted file mode 100644 index 78ca54e93a0..00000000000 --- a/packages/sanity/src/core/store/key-value/backends/types.ts +++ /dev/null @@ -1,15 +0,0 @@ -import {type Observable} from 'rxjs' - -import {type KeyValueStoreValue} from '../types' - -export interface KeyValuePair { - key: string - value: KeyValueStoreValue | null -} - -export interface Backend { - getKey: (key: string) => Observable - setKey: (key: string, nextValue: unknown) => Observable - getKeys: (keys: string[]) => Observable - setKeys: (keyValuePairs: KeyValuePair[]) => Observable -} diff --git a/packages/sanity/src/core/store/key-value/index.ts b/packages/sanity/src/core/store/key-value/index.ts index 574ccc92075..3a27f7cfb8c 100644 --- a/packages/sanity/src/core/store/key-value/index.ts +++ b/packages/sanity/src/core/store/key-value/index.ts @@ -1,2 +1,2 @@ -export * from './KeyValueStore' +export * from './keyValueStore' export * from './types' diff --git a/packages/sanity/src/core/store/key-value/keyValueStore.ts b/packages/sanity/src/core/store/key-value/keyValueStore.ts new file mode 100644 index 00000000000..9c5470ed1d9 --- /dev/null +++ b/packages/sanity/src/core/store/key-value/keyValueStore.ts @@ -0,0 +1,9 @@ +import {type SanityClient} from '@sanity/client' + +import {withLocalStorageSWR} from './localStorageSWR' +import {createServerKeyValueStore} from './serverKeyValueStore' + +/** @internal */ +export function createKeyValueStore(options: {client: SanityClient}) { + return withLocalStorageSWR(createServerKeyValueStore(options)) +} diff --git a/packages/sanity/src/core/store/key-value/localStorageSWR.ts b/packages/sanity/src/core/store/key-value/localStorageSWR.ts new file mode 100644 index 00000000000..d4a4273d6ea --- /dev/null +++ b/packages/sanity/src/core/store/key-value/localStorageSWR.ts @@ -0,0 +1,39 @@ +import {isEqual} from 'lodash' +import {fromEvent, merge, NEVER} from 'rxjs' +import {distinctUntilChanged, filter, map, tap} from 'rxjs/operators' + +import {localStoreStorage} from './storage/localStoreStorage' +import {type KeyValueStore, type KeyValueStoreValue} from './types' + +// Whether or not to enable instant user sync between tabs +// if set to true, the setting will update instantly across all tabs +const ENABLE_CROSS_TAB_SYNC = false + +/** + * Wraps a KeyValueStore and adds Stale-While-Revalidate (SWR) behavior to it + */ +export function withLocalStorageSWR(wrappedStore: KeyValueStore): KeyValueStore { + const storageEvent = ENABLE_CROSS_TAB_SYNC ? fromEvent(window, 'storage') : NEVER + + function getKey(key: string) { + const lsUpdates = storageEvent.pipe( + filter((event) => event.key === key), + map(() => localStoreStorage.getKey(key)), + ) + + return merge(lsUpdates, wrappedStore.getKey(key)).pipe( + distinctUntilChanged(isEqual), + tap((value) => { + localStoreStorage.setKey(key, value) + }), + ) + } + function setKey(key: string, nextValue: KeyValueStoreValue) { + localStoreStorage.setKey(key, nextValue) + return wrappedStore.setKey(key, nextValue) + } + return { + getKey, + setKey, + } +} diff --git a/packages/sanity/src/core/store/key-value/serverKeyValueStore.ts b/packages/sanity/src/core/store/key-value/serverKeyValueStore.ts new file mode 100644 index 00000000000..646bfff9639 --- /dev/null +++ b/packages/sanity/src/core/store/key-value/serverKeyValueStore.ts @@ -0,0 +1,49 @@ +import {type SanityClient} from '@sanity/client' +import {isEqual} from 'lodash' +import {concat, type Observable, Subject} from 'rxjs' +import {distinctUntilChanged, filter, map} from 'rxjs/operators' + +import {createServerStorage} from './storage/serverStorage' +import {type KeyValueStore, type KeyValueStoreValue} from './types' + +export function createServerKeyValueStore({client}: {client: SanityClient}): KeyValueStore { + const serverStorage = createServerStorage({client}) + + const events$ = new Subject<{ + type: 'optimistic' | 'commit' + key: string + value: KeyValueStoreValue + }>() + + function getKey(key: string) { + return serverStorage.getKey(key) + } + + function setKey(key: string, value: KeyValueStoreValue) { + events$.next({type: 'optimistic', key, value}) + + /* + * The backend returns the result of the set operation, so we can just pass that along. + * Most utils do not use it (they will take advantage of local state first) but it reflects the + * backend function and could be useful for debugging. + */ + return serverStorage.setKey(key, value).then((storedValue) => { + events$.next({type: 'commit', key, value: storedValue}) + return storedValue + }) + } + + return { + getKey(key: string): Observable { + return concat( + getKey(key), + events$.pipe( + filter((event) => event.key === key), + map((event) => event.value), + distinctUntilChanged(isEqual), + ), + ) + }, + setKey, + } +} diff --git a/packages/sanity/src/core/store/key-value/storage/localStoreStorage.ts b/packages/sanity/src/core/store/key-value/storage/localStoreStorage.ts new file mode 100644 index 00000000000..376faa51e91 --- /dev/null +++ b/packages/sanity/src/core/store/key-value/storage/localStoreStorage.ts @@ -0,0 +1,38 @@ +import {supportsLocalStorage} from '../../../util/supportsLocalStorage' +import {type KeyValueStoreValue} from '../types' +import {createMemoryStorage} from './memoryStorage' + +function tryParse(val: string) { + try { + return JSON.parse(val) + } catch (err) { + // eslint-disable-next-line no-console + console.warn(`Failed to parse settings: ${err.message}`) + return null + } +} + +function createLocalStoreStorage() { + if (!supportsLocalStorage) { + return createMemoryStorage() + } + + function getKey(key: string): KeyValueStoreValue | null { + const val = localStorage.getItem(key) + + return val === null ? null : tryParse(val) + } + + const setKey = function (key: string, nextValue: KeyValueStoreValue) { + // Can't stringify undefined, and nulls are what + // `getItem` returns when key does not exist + if (typeof nextValue === 'undefined' || nextValue === null) { + localStorage.removeItem(key) + } else { + localStorage.setItem(key, JSON.stringify(nextValue)) + } + } + return {getKey, setKey} +} + +export const localStoreStorage = createLocalStoreStorage() diff --git a/packages/sanity/src/core/store/key-value/storage/memoryStorage.ts b/packages/sanity/src/core/store/key-value/storage/memoryStorage.ts new file mode 100644 index 00000000000..7afbc82efea --- /dev/null +++ b/packages/sanity/src/core/store/key-value/storage/memoryStorage.ts @@ -0,0 +1,13 @@ +import {type KeyValueStoreValue} from '../types' + +export function createMemoryStorage() { + const DB = Object.create(null) + return { + getKey(key: string): KeyValueStoreValue | null { + return DB[key] || null + }, + setKey(key: string, value: KeyValueStoreValue) { + DB[key] = value + }, + } +} diff --git a/packages/sanity/src/core/store/key-value/backends/server.ts b/packages/sanity/src/core/store/key-value/storage/serverStorage.ts similarity index 52% rename from packages/sanity/src/core/store/key-value/backends/server.ts rename to packages/sanity/src/core/store/key-value/storage/serverStorage.ts index 5f76539ad2a..697c8cfb686 100644 --- a/packages/sanity/src/core/store/key-value/backends/server.ts +++ b/packages/sanity/src/core/store/key-value/storage/serverStorage.ts @@ -1,22 +1,25 @@ import {type SanityClient} from '@sanity/client' import DataLoader from 'dataloader' -import {catchError, from, map, of} from 'rxjs' import {DEFAULT_STUDIO_CLIENT_OPTIONS} from '../../../studioClient' import {type KeyValueStoreValue} from '../types' -import {type Backend, type KeyValuePair} from './types' +import {type KeyValuePair} from './types' /** @internal */ -export interface ServerBackendOptions { +export interface ServerStorageOptions { client: SanityClient } +export interface ServerStorage { + getKey: (key: string) => Promise + setKey: (key: string, nextValue: unknown) => Promise +} + /** - * One of serveral possible backends for KeyValueStore. This backend uses the - * Sanity client to store and retrieve key-value pairs from the /users/me/keyvalue endpoint. + * Backend uses the Sanity client to store and retrieve key-value pairs from the /users/me/keyvalue endpoint. * @internal */ -export function serverBackend({client: _client}: ServerBackendOptions): Backend { +export function createServerStorage({client: _client}: ServerStorageOptions): ServerStorage { const client = _client.withConfig(DEFAULT_STUDIO_CLIENT_OPTIONS) const keyValueLoader = new DataLoader(async (keys) => { @@ -40,50 +43,37 @@ export function serverBackend({client: _client}: ServerBackendOptions): Backend {} as Record, ) - const result = keys.map((key) => keyValuePairs[key] || null) - return result + return keys.map((key) => keyValuePairs[key] || null) }) - const getKeys = (keys: string[]) => { - return from(keyValueLoader.loadMany(keys)) + const getKey = (key: string) => { + return keyValueLoader.load(key) } - const setKeys = (keyValuePairs: KeyValuePair[]) => { - return from( - client.request({ + const setKey = (key: string, nextValue: unknown) => { + return client + .request({ method: 'PUT', uri: `/users/me/keyvalue`, - body: keyValuePairs, + body: [{key, value: nextValue}], withCredentials: true, - }), - ).pipe( - map((response) => { - return response.map((pair) => { + }) + .then( + (response) => { + const pair = response[0] keyValueLoader.clear(pair.key) keyValueLoader.prime(pair.key, pair.value) - return pair.value - }) - }), - catchError((error) => { - console.error('Error setting data:', error) - return of(Array(keyValuePairs.length).fill(null)) - }), - ) - } - - const getKey = (key: string) => { - return getKeys([key]).pipe(map((values) => values[0])) - } - - const setKey = (key: string, nextValue: unknown) => { - return setKeys([{key, value: nextValue as KeyValueStoreValue}]).pipe(map((values) => values[0])) + }, + (error) => { + console.error('Error setting data:', error) + return null + }, + ) } return { getKey, setKey, - getKeys, - setKeys, } } diff --git a/packages/sanity/src/core/store/key-value/storage/types.ts b/packages/sanity/src/core/store/key-value/storage/types.ts new file mode 100644 index 00000000000..77c65780bbb --- /dev/null +++ b/packages/sanity/src/core/store/key-value/storage/types.ts @@ -0,0 +1,6 @@ +import {type KeyValueStoreValue} from '../types' + +export interface KeyValuePair { + key: string + value: KeyValueStoreValue | null +} diff --git a/packages/sanity/src/core/store/key-value/types.ts b/packages/sanity/src/core/store/key-value/types.ts index a7c18bcdc3b..c3c60917899 100644 --- a/packages/sanity/src/core/store/key-value/types.ts +++ b/packages/sanity/src/core/store/key-value/types.ts @@ -12,5 +12,5 @@ export type KeyValueStoreValue = JsonPrimitive | JsonObject | JsonArray /** @internal */ export interface KeyValueStore { getKey(key: string): Observable - setKey(key: string, value: KeyValueStoreValue): Observable + setKey(key: string, value: KeyValueStoreValue): Promise } diff --git a/packages/sanity/src/structure/useStructureToolSetting.ts b/packages/sanity/src/structure/useStructureToolSetting.ts index 58f38fc3e04..42a60c9f8e0 100644 --- a/packages/sanity/src/structure/useStructureToolSetting.ts +++ b/packages/sanity/src/structure/useStructureToolSetting.ts @@ -1,5 +1,5 @@ -import {useCallback, useEffect, useMemo, useState} from 'react' -import {map, startWith} from 'rxjs/operators' +import {useCallback, useMemo} from 'react' +import {useObservable} from 'react-rx' import {useKeyValueStore} from 'sanity' const STRUCTURE_TOOL_NAMESPACE = 'studio.structure-tool' @@ -13,33 +13,17 @@ export function useStructureToolSetting( defaultValue?: ValueType, ): [ValueType | undefined, (_value: ValueType) => void] { const keyValueStore = useKeyValueStore() - const [value, setValue] = useState(defaultValue) const keyValueStoreKey = [STRUCTURE_TOOL_NAMESPACE, namespace, key].filter(Boolean).join('.') - const settings = useMemo(() => { + const value$ = useMemo(() => { return keyValueStore.getKey(keyValueStoreKey) }, [keyValueStore, keyValueStoreKey]) - useEffect(() => { - const sub = settings - .pipe( - startWith(defaultValue), - map((fetchedValue) => { - return fetchedValue === null ? defaultValue : fetchedValue - }), - ) - .subscribe({ - next: setValue as any, - }) - - return () => sub?.unsubscribe() - }, [defaultValue, keyValueStoreKey, settings]) - + const value = useObservable(value$, defaultValue) as ValueType const set = useCallback( (newValue: ValueType) => { if (newValue !== value) { - setValue(newValue) keyValueStore.setKey(keyValueStoreKey, newValue as string) } },