From ad9a3158c401a4c3b318d7031461ba9e36ff4e8b Mon Sep 17 00:00:00 2001 From: Martti Malmi Date: Tue, 29 Aug 2023 13:40:43 +0300 Subject: [PATCH] localState refactoring --- src/js/Main.tsx | 2 +- src/js/nostr/Events.ts | 2 +- src/js/state/LocalForageAdapter.ts | 28 +++---- src/js/state/MemoryAdapter.ts | 24 ++++++ src/js/state/Node.test.ts | 64 ++++++++++++-- src/js/state/Node.ts | 130 +++++++++++------------------ src/js/state/types.ts | 13 ++- src/js/state/useLocalState.ts | 10 ++- src/js/views/explorer/Explorer.tsx | 3 - 9 files changed, 162 insertions(+), 114 deletions(-) create mode 100644 src/js/state/MemoryAdapter.ts diff --git a/src/js/Main.tsx b/src/js/Main.tsx index 43706963..3f098a2b 100644 --- a/src/js/Main.tsx +++ b/src/js/Main.tsx @@ -3,6 +3,7 @@ import { Helmet } from 'react-helmet'; import { Router, RouterOnChangeArgs } from 'preact-router'; import useLocalState from '@/state/useLocalState.ts'; +import Explorer from '@/views/explorer/Explorer.tsx'; import Footer from './components/Footer'; import Show from './components/helpers/Show'; @@ -31,7 +32,6 @@ import Subscribe from './views/Subscribe'; import '@fontsource/lato/400.css'; import '@fontsource/lato/700.css'; import '../css/cropper.min.css'; -import Explorer from "@/views/explorer/Explorer.tsx"; const Main = () => { const [loggedIn] = useLocalState('loggedIn', false); diff --git a/src/js/nostr/Events.ts b/src/js/nostr/Events.ts index b0933ca8..58ab092d 100644 --- a/src/js/nostr/Events.ts +++ b/src/js/nostr/Events.ts @@ -22,7 +22,7 @@ import { import { ID, STR, UniqueIds } from '@/utils/UniqueIds'; import localState from '../state/LocalState.ts'; -import { Node } from '../state/LocalState.ts'; +import Node from '../state/Node.ts'; import { DecryptedEvent } from '../views/chat/ChatMessages'; import { addGroup, setGroupNameByInvite } from '../views/chat/NewChat'; diff --git a/src/js/state/LocalForageAdapter.ts b/src/js/state/LocalForageAdapter.ts index 1958bf62..160df662 100644 --- a/src/js/state/LocalForageAdapter.ts +++ b/src/js/state/LocalForageAdapter.ts @@ -1,10 +1,6 @@ import localForage from 'localforage'; -import { Adapter, Callback } from '@/state/types.ts'; - -// Localforage returns null if an item is not found, so we represent null with this uuid instead. -// not foolproof, but good enough for now. -const LOCALFORAGE_NULL = 'c2fc1ad0-f76f-11ec-b939-0242ac120002'; +import { Adapter, Callback, NodeValue } from '@/state/types.ts'; localForage.config({ driver: [localForage.LOCALSTORAGE, localForage.INDEXEDDB, localForage.WEBSQL], @@ -14,21 +10,19 @@ const unsub = () => {}; export default class LocalForageAdapter extends Adapter { get(path: string, callback: Callback) { - localForage.getItem(path).then((result) => { - if (result === null) { - result = undefined; - } else if (result === LOCALFORAGE_NULL) { - result = null; - } - callback(result, path, unsub); - }); + localForage + .getItem(path) + .then((result) => { + if (result) { + callback(result.value, path, result.updatedAt, unsub); + } + }) + .catch((err) => console.error(err)); return unsub; } - set(path: string, data: any) { - if (data === null) { - localForage.setItem(path, LOCALFORAGE_NULL); - } else if (data === undefined) { + set(path: string, data: NodeValue) { + if (data === undefined) { localForage.removeItem(path); } else { localForage.setItem(path, data); diff --git a/src/js/state/MemoryAdapter.ts b/src/js/state/MemoryAdapter.ts new file mode 100644 index 00000000..6500bd8c --- /dev/null +++ b/src/js/state/MemoryAdapter.ts @@ -0,0 +1,24 @@ +import { Adapter, Callback, NodeValue, Unsubscribe } from '@/state/types.ts'; + +export default class MemoryAdapter extends Adapter { + private storage = new Map(); + + get(path: string, callback: Callback): Unsubscribe { + const storedValue = this.storage.get(path); + if (storedValue) { + callback(storedValue.value, path, storedValue.updatedAt, () => {}); + } + return () => {}; + } + + set(path: string, value: NodeValue): void { + if (!value.updatedAt || !value.value) { + throw new Error(`Invalid value: ${JSON.stringify(value)}`); + } + if (value === undefined) { + this.storage.delete(path); + } else { + this.storage.set(path, value); + } + } +} diff --git a/src/js/state/Node.test.ts b/src/js/state/Node.test.ts index 18f7a2b2..5d3e9dbf 100644 --- a/src/js/state/Node.test.ts +++ b/src/js/state/Node.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import MemoryAdapter from '@/state/MemoryAdapter.ts'; import { Callback, Unsubscribe } from '@/state/types.ts'; import Node from './Node'; @@ -15,14 +16,19 @@ describe('Node', () => { }); it('should subscribe and unsubscribe with on()', () => { - const node = new Node({ id: 'test', adapters: [] }); + const node = new Node({ id: 'test', adapters: [new MemoryAdapter()] }); const mockCallback: Callback = vi.fn(); const unsubscribe: Unsubscribe = node.on(mockCallback); expect(typeof unsubscribe).toBe('function'); node.put('someValue'); - expect(mockCallback).toHaveBeenCalledWith('someValue', 'test', expect.any(Function)); + expect(mockCallback).toHaveBeenCalledWith( + 'someValue', + 'test', + expect.any(Number), + expect.any(Function), + ); unsubscribe(); node.put('someValue2'); @@ -30,21 +36,67 @@ describe('Node', () => { }); it('should callback when subscribed after put()', () => { - const node = new Node({ id: 'test', adapters: [] }); + const node = new Node({ id: 'test', adapters: [new MemoryAdapter()] }); const mockCallback: Callback = vi.fn(); node.put('someValue'); node.on(mockCallback); - expect(mockCallback).toHaveBeenCalledWith('someValue', 'test', expect.any(Function)); + expect(mockCallback).toHaveBeenCalledWith( + 'someValue', + 'test', + expect.any(Number), + expect.any(Function), + ); }); it('should trigger callback once when calling once()', async () => { - const node = new Node({ id: 'test', adapters: [] }); + const node = new Node({ id: 'test', adapters: [new MemoryAdapter()] }); const mockCallback: Callback = vi.fn(); node.put('someValue'); const result = await node.once(mockCallback); expect(result).toBe('someValue'); - expect(mockCallback).toHaveBeenCalledWith('someValue', 'test', expect.any(Function)); + expect(mockCallback).toHaveBeenCalledWith( + 'someValue', + 'test', + expect.any(Number), + expect.any(Function), + ); + + node.put('someValue2'); + expect(mockCallback).toHaveBeenCalledTimes(1); + }); + + it('should trigger list callbacks when children are present', async () => { + const node = new Node({ id: 'test', adapters: [new MemoryAdapter()] }); + const mockCallback: Callback = vi.fn(); + + // Adding children to the node + await node.get('child1').put('value1'); + await node.get('child2').put('value2'); + + const unsubscribe: Unsubscribe = node.map(mockCallback); + + // Should trigger for both child nodes + expect(mockCallback).toHaveBeenCalledWith( + 'value1', + 'test/child1', + expect.any(Number), + expect.any(Function), + ); + expect(mockCallback).toHaveBeenCalledWith( + 'value2', + 'test/child2', + expect.any(Number), + expect.any(Function), + ); + + unsubscribe(); + + // Add another child to ensure the callback is not called after unsubscribe + await node.get('child3').put('value3'); + + // Should still have been called only twice + expect(mockCallback).toHaveBeenCalledTimes(2); }); }); diff --git a/src/js/state/Node.ts b/src/js/state/Node.ts index 25db127b..dc72d7df 100644 --- a/src/js/state/Node.ts +++ b/src/js/state/Node.ts @@ -1,7 +1,6 @@ -import debounce from 'lodash/debounce'; - import LocalForageAdapter from '@/state/LocalForageAdapter.ts'; -import { Adapter, Callback, Unsubscribe } from '@/state/types.ts'; +import MemoryAdapter from '@/state/MemoryAdapter.ts'; +import { Adapter, Callback, NodeValue, Unsubscribe } from '@/state/types.ts'; /** Inspired by https://github.com/amark/gun @@ -17,38 +16,17 @@ export default class Node { id: string; parent: Node | null; children = new Map(); - on_subscriptions = new Map(); - map_subscriptions = new Map(); + on_subscriptions = new Map(); + map_subscriptions = new Map(); adapters: Adapter[]; - value: any = undefined; counter = 0; constructor({ id = '', adapters, parent = null }: NodeProps = {}) { this.id = id; this.parent = parent; - this.adapters = adapters ?? parent?.adapters ?? [new LocalForageAdapter()]; + this.adapters = adapters ?? parent?.adapters ?? [new MemoryAdapter(), new LocalForageAdapter()]; } - doCallbacks = debounce( - () => { - for (const [id, callback] of this.on_subscriptions) { - const unsubscribe = () => this.on_subscriptions.delete(id); - this.once(callback, unsubscribe, false); - } - if (this.parent) { - this.parent.doCallbacks(); // maybe this shouldn't be recursive after all? in a file tree analogy, you wouldn't want - // a change in a subdirectory to trigger a callback in all parent directories. - // there could be a separate open() fn for recursive subscriptions. - for (const [id, callback] of this.parent.map_subscriptions) { - const unsubscribe = () => this.parent?.map_subscriptions.delete(id); - this.once(callback, unsubscribe, false); - } - } - }, - 20, - { maxWait: 40, leading: true }, - ); - /** * * @param key @@ -70,58 +48,21 @@ export default class Node { * @param value * @example node.get('users').get('alice').put({name: 'Alice'}) */ - async put(value) { - // console.log('put', this.id, value); - if (Array.isArray(value)) { - throw new Error("Sorry, we don't deal with arrays"); - } + async put(value, updatedAt = Date.now()) { if (typeof value === 'object' && value !== null) { - this.value = undefined; - await Promise.all(Object.entries(value).map(([key, val]) => this.get(key).put(val))); + this.adapters.forEach((adapter) => adapter.set(this.id, { value: '__DIR__', updatedAt })); + const children = Object.keys(value); + children.map((key) => this.get(key).put(value[key], updatedAt)); } else { this.children = new Map(); - this.value = value; - } - this.doCallbacks(); - this.adapters.forEach((adapter) => adapter.set(this.id, this.value)); - } - - /** - * Return a value without subscribing to it - * @param callback - * @param event - * @param returnIfUndefined - * @returns {Promise<*>} - */ - async once( - callback?: Callback, - unsubscribe?: Unsubscribe, - returnIfUndefined = true, - ): Promise { - let result: any; - if (this.children.size) { - // return an object containing all children - result = {}; - await Promise.all( - Array.from(this.children.keys()).map(async (key) => { - result[key] = await this.get(key).once(undefined, unsubscribe); - }), - ); - } else if (this.value !== undefined) { - result = this.value; - } - - if (result !== undefined || returnIfUndefined) { - callback && - callback( - result, - this.id.slice(this.id.lastIndexOf('/') + 1), - unsubscribe || - (() => { - /* do nothing */ - }), - ); - return result; + const nodeValue: NodeValue = { + updatedAt, + value, + }; + this.adapters.forEach((adapter) => adapter.set(this.id, nodeValue)); + this.on_subscriptions.forEach((callback) => { + callback(value, this.id, updatedAt, () => {}); + }); } } @@ -130,29 +71,54 @@ export default class Node { * @param callback */ on(callback: Callback): Unsubscribe { + let latest: NodeValue | null = null; + // TODO handle case where it's a branch node + const cb = (value, path, updatedAt, unsubscribe) => { + if (latest === null || latest.updatedAt < value.updatedAt) { + latest = { value, updatedAt }; + callback(value, path, updatedAt, unsubscribe); + } + }; const subId = this.counter++; - this.on_subscriptions.set(subId, callback); - const adapterSubs = this.adapters.map((adapter) => adapter.get(this.id, callback)); + this.on_subscriptions.set(subId, cb); + const adapterSubs = this.adapters.map((adapter) => adapter.get(this.id, cb)); const unsubscribe = () => { this.on_subscriptions.delete(subId); adapterSubs.forEach((unsub) => unsub()); }; - this.once(callback, unsubscribe, false); return unsubscribe; } /** - * Subscribe to the children of a node. Callback is called separately for each child. + * Callback for each child node * @param callback - * @returns {Promise} */ map(callback: Callback): Unsubscribe { const id = this.counter++; this.map_subscriptions.set(id, callback); const unsubscribe = () => this.map_subscriptions.delete(id); for (const child of this.children.values()) { - child.once(callback, unsubscribe, false); + child.once(callback, unsubscribe); } return unsubscribe; } + + /** + * Same as on(), but will unsubscribe after the first callback + * @param callback + * @param unsubscribe + */ + once(callback?: Callback, unsubscribe?: Unsubscribe): Promise { + return new Promise((resolve) => { + const cb = (value, updatedAt, path, unsub) => { + if (unsubscribe) { + unsubscribe(); + } + resolve(value); + callback?.(value, updatedAt, path, unsub); + unsub(); + }; + this.on(cb); + }); + } } diff --git a/src/js/state/types.ts b/src/js/state/types.ts index c5393130..cb5262c5 100644 --- a/src/js/state/types.ts +++ b/src/js/state/types.ts @@ -1,6 +1,15 @@ export type Unsubscribe = () => void; -export type Callback = (data: any, path: string, unsubscribe: Unsubscribe) => void; +export type NodeValue = { + updatedAt: number; + value: any; +}; +export type Callback = ( + value: any, + path: string, + updatedAt: number, + unsubscribe: Unsubscribe, +) => void; export abstract class Adapter { abstract get(path: string, callback: Callback): Unsubscribe; - abstract set(path: string, data: any): void; + abstract set(path: string, data: NodeValue): void; } diff --git a/src/js/state/useLocalState.ts b/src/js/state/useLocalState.ts index d5d53df6..b4bf3115 100644 --- a/src/js/state/useLocalState.ts +++ b/src/js/state/useLocalState.ts @@ -3,9 +3,15 @@ import { useCallback, useEffect, useState } from 'react'; import localState from '@/state/LocalState.ts'; export default function useLocalState(key: string, initialValue: any = undefined, once = false) { - const [value, setValue] = useState(initialValue || localState.get(key).value); + if (!initialValue) { + localState.get(key).once((val) => { + // TODO some way to get memory value + initialValue = val; + }); + } + const [value, setValue] = useState(initialValue); useEffect(() => { - const unsub = localState.get(key).on((new_value, _key, unsubscribe) => { + const unsub = localState.get(key).on((new_value, _key, _updatedAt, unsubscribe) => { setValue(new_value); if (once) { unsubscribe(); diff --git a/src/js/views/explorer/Explorer.tsx b/src/js/views/explorer/Explorer.tsx index 21b8c7e0..0452c4f0 100644 --- a/src/js/views/explorer/Explorer.tsx +++ b/src/js/views/explorer/Explorer.tsx @@ -1,5 +1,4 @@ import View from '@/views/View.tsx'; -import useSubscribe from "@/nostr/hooks/useSubscribe.ts"; type Props = { p?: string; @@ -7,8 +6,6 @@ type Props = { }; const Explorer = ({ p }: Props) => { - const all = useSubscribe({ kinds: [30000], authors: [Key.getPubKey()] }); - return (
{p}