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:
2026-07-07 23:24:08 -04:00
co-authored by Claude Opus 4.8
parent b1ee3ada98
commit 4fc3f7a35f
4 changed files with 171 additions and 269 deletions
+14 -91
View File
@@ -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 Reminder = {
roomId: string;
@@ -16,78 +15,14 @@ type RemindersContent = {
reminders: Reminder[];
};
function readReminders(mx: MatrixClient): Reminder[] {
return getAccountData<RemindersContent>(mx, REMINDERS_KEY)?.reminders ?? [];
}
// Module-scoped serialization state.
//
// The latest snapshot and the write queue must be shared across every hook
// instance: ReminderMonitor (auto-removes fired reminders) and RemindMeDialog
// (adds reminders) mount separate hooks, and a per-instance queue would let a
// remove and an add race across instances and clobber each other (setAccountData
// replaces the whole content, no server merge). We therefore keep a single
// shared queue + latest ref, keyed off the active MatrixClient.
type ReminderModuleState = {
mx: MatrixClient;
latest: Reminder[];
writeQueue: Promise<unknown>;
listeners: Set<(list: Reminder[]) => void>;
onAccountData: ClientEventHandlerMap[ClientEvent.AccountData];
};
let moduleState: ReminderModuleState | 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): ReminderModuleState {
if (moduleState && moduleState.mx === mx) {
return moduleState;
}
if (moduleState) {
moduleState.mx.removeListener(ClientEvent.AccountData, moduleState.onAccountData);
}
const state: ReminderModuleState = {
mx,
latest: readReminders(mx),
writeQueue: Promise.resolve(),
listeners: new Set(),
// Reassigned below once `state` is captured.
onAccountData: () => undefined,
};
state.onAccountData = (evt) => {
if (evt.getType() === REMINDERS_KEY) {
const list = evt.getContent<RemindersContent>()?.reminders ?? [];
state.latest = list;
state.listeners.forEach((listener) => listener(list));
}
};
mx.on(ClientEvent.AccountData, state.onAccountData);
moduleState = state;
return state;
}
function enqueueReminderWrite(
mx: MatrixClient,
compute: (current: Reminder[]) => Reminder[],
): 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, REMINDERS_KEY, { reminders: 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 remindersStore = createAccountDataListStore<Reminder[], RemindersContent>({
eventType: REMINDERS_KEY,
read: (content) => content?.reminders ?? [],
write: (reminders) => ({ reminders }),
});
export function useReminders(): {
reminders: Reminder[];
@@ -96,34 +31,22 @@ export function useReminders(): {
getReminders: () => Reminder[];
} {
const mx = useMatrixClient();
const [reminders, setReminders] = useState<Reminder[]>(() => 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);
setReminders(state.latest);
state.listeners.add(setReminders);
return () => {
state.listeners.delete(setReminders);
};
}, [mx]);
const reminders = remindersStore.useValue(mx);
const addReminder = useCallback(
(r: Reminder) => enqueueReminderWrite(mx, (current) => [...current, r]),
(r: Reminder) => remindersStore.enqueueWrite(mx, (current) => [...current, r]),
[mx],
);
const removeReminder = useCallback(
(eventId: string, timestamp: number) =>
enqueueReminderWrite(mx, (current) =>
remindersStore.enqueueWrite(mx, (current) =>
current.filter((r) => !(r.eventId === eventId && r.timestamp === timestamp)),
),
[mx],
);
const getReminders = useCallback(() => ensureModuleState(mx).latest, [mx]);
const getReminders = useCallback(() => remindersStore.getLatest(mx), [mx]);
return { reminders, addReminder, removeReminder, getReminders };
}