DP13: extract shared account-data list-store engine
useBookmarks / useReminders / useUserNotes were near-verbatim copies of a
concurrency-critical engine (module-scoped singleton, per-client
subscribe/teardown, a serialized write-queue that prevents lost-update
clobbering, a listener Set, and the account-data subscription).
Extract it into createAccountDataListStore<T, C>({ eventType, read, write }) in
src/app/hooks/createAccountDataListStore.ts. The write-serialization semantics
are preserved identically (still the lost-update fix). The differing payload
shapes are parameterized via read/write: bookmarks/reminders wrap a list
({bookmarks}/{reminders}); notes is a flat Record passed through unchanged.
The three hooks become thin wrappers with their exact public APIs unchanged
(same exported names, signatures, return shapes, and mutators), so no call site
changes. The DP16 setAccountData helper is used inside the queue.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { ClientEvent, ClientEventHandlerMap, MatrixClient } from 'matrix-js-sdk';
|
||||
import { useCallback } from 'react';
|
||||
import { useMatrixClient } from './useMatrixClient';
|
||||
import { getAccountData, setAccountData } from '../utils/accountData';
|
||||
import { createAccountDataListStore } from './createAccountDataListStore';
|
||||
|
||||
export type Bookmark = {
|
||||
roomId: string;
|
||||
@@ -18,78 +17,14 @@ type BookmarksContent = {
|
||||
bookmarks: Bookmark[];
|
||||
};
|
||||
|
||||
function readBookmarks(mx: MatrixClient): Bookmark[] {
|
||||
return getAccountData<BookmarksContent>(mx, BOOKMARKS_KEY)?.bookmarks ?? [];
|
||||
}
|
||||
|
||||
// Module-scoped serialization state.
|
||||
//
|
||||
// useBookmarks() is mounted once per message row (dozens of live instances), so
|
||||
// a per-instance latest/queue would only serialize writes within a single row —
|
||||
// bookmarking message A then message B from different rows (before the server
|
||||
// echo lands) would let B compute from a stale snapshot and clobber A
|
||||
// (setAccountData replaces the whole content, no server merge). We therefore
|
||||
// keep a single shared latest ref + write queue, keyed off the active client.
|
||||
type BookmarksModuleState = {
|
||||
mx: MatrixClient;
|
||||
latest: Bookmark[];
|
||||
writeQueue: Promise<unknown>;
|
||||
listeners: Set<(list: Bookmark[]) => void>;
|
||||
onAccountData: ClientEventHandlerMap[ClientEvent.AccountData];
|
||||
};
|
||||
|
||||
let moduleState: BookmarksModuleState | null = null;
|
||||
|
||||
// Lazily initialize the shared state for the given client. On a client change
|
||||
// (login/logout swaps the MatrixClient) we tear down the old subscription and
|
||||
// re-initialize against the new client so we never leak or double-subscribe.
|
||||
function ensureModuleState(mx: MatrixClient): BookmarksModuleState {
|
||||
if (moduleState && moduleState.mx === mx) {
|
||||
return moduleState;
|
||||
}
|
||||
|
||||
if (moduleState) {
|
||||
moduleState.mx.removeListener(ClientEvent.AccountData, moduleState.onAccountData);
|
||||
}
|
||||
|
||||
const state: BookmarksModuleState = {
|
||||
mx,
|
||||
latest: readBookmarks(mx),
|
||||
writeQueue: Promise.resolve(),
|
||||
listeners: new Set(),
|
||||
// Reassigned below once `state` is captured.
|
||||
onAccountData: () => undefined,
|
||||
};
|
||||
|
||||
state.onAccountData = (evt) => {
|
||||
if (evt.getType() === BOOKMARKS_KEY) {
|
||||
const list = evt.getContent<BookmarksContent>()?.bookmarks ?? [];
|
||||
state.latest = list;
|
||||
state.listeners.forEach((listener) => listener(list));
|
||||
}
|
||||
};
|
||||
|
||||
mx.on(ClientEvent.AccountData, state.onAccountData);
|
||||
moduleState = state;
|
||||
return state;
|
||||
}
|
||||
|
||||
function enqueueBookmarkWrite(
|
||||
mx: MatrixClient,
|
||||
compute: (current: Bookmark[]) => Bookmark[],
|
||||
): Promise<void> {
|
||||
const state = ensureModuleState(mx);
|
||||
const run = state.writeQueue.then(async () => {
|
||||
const next = compute(state.latest);
|
||||
state.latest = next;
|
||||
state.listeners.forEach((listener) => listener(next));
|
||||
await setAccountData(mx, BOOKMARKS_KEY, { bookmarks: next });
|
||||
});
|
||||
// Keep the chain alive even if one write rejects, but propagate the
|
||||
// rejection to this caller so it can react (e.g. retry).
|
||||
state.writeQueue = run.catch(() => undefined);
|
||||
return run;
|
||||
}
|
||||
// Shared, concurrency-safe store. See createAccountDataListStore for why the
|
||||
// snapshot + write queue must be module-scoped (writes are serialized to avoid
|
||||
// lost updates, since setAccountData replaces the whole content with no merge).
|
||||
const bookmarksStore = createAccountDataListStore<Bookmark[], BookmarksContent>({
|
||||
eventType: BOOKMARKS_KEY,
|
||||
read: (content) => content?.bookmarks ?? [],
|
||||
write: (bookmarks) => ({ bookmarks }),
|
||||
});
|
||||
|
||||
export function useBookmarks(): {
|
||||
bookmarks: Bookmark[];
|
||||
@@ -98,23 +33,11 @@ export function useBookmarks(): {
|
||||
isBookmarked: (eventId: string) => boolean;
|
||||
} {
|
||||
const mx = useMatrixClient();
|
||||
const [bookmarks, setBookmarks] = useState<Bookmark[]>(() => ensureModuleState(mx).latest);
|
||||
|
||||
// Subscribe to the shared module state. A single AccountData listener is
|
||||
// installed per client (in ensureModuleState); each hook instance only
|
||||
// registers a local setter and unregisters it on unmount / client change.
|
||||
useEffect(() => {
|
||||
const state = ensureModuleState(mx);
|
||||
setBookmarks(state.latest);
|
||||
state.listeners.add(setBookmarks);
|
||||
return () => {
|
||||
state.listeners.delete(setBookmarks);
|
||||
};
|
||||
}, [mx]);
|
||||
const bookmarks = bookmarksStore.useValue(mx);
|
||||
|
||||
const addBookmark = useCallback(
|
||||
(b: Bookmark) =>
|
||||
enqueueBookmarkWrite(mx, (current) => {
|
||||
bookmarksStore.enqueueWrite(mx, (current) => {
|
||||
// Avoid duplicates
|
||||
const filtered = current.filter((bk) => bk.eventId !== b.eventId);
|
||||
let next = [b, ...filtered];
|
||||
@@ -128,7 +51,7 @@ export function useBookmarks(): {
|
||||
|
||||
const removeBookmark = useCallback(
|
||||
(eventId: string) =>
|
||||
enqueueBookmarkWrite(mx, (current) => current.filter((bk) => bk.eventId !== eventId)),
|
||||
bookmarksStore.enqueueWrite(mx, (current) => current.filter((bk) => bk.eventId !== eventId)),
|
||||
[mx],
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user