Files
cinny/src/app/hooks/createAccountDataListStore.ts
T
jaredandClaude Opus 4.8 4fc3f7a35f 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>
2026-07-07 23:24:08 -04:00

131 lines
5.0 KiB
TypeScript

import { useEffect, useState } from 'react';
import { ClientEvent, ClientEventHandlerMap, MatrixClient } from 'matrix-js-sdk';
import { AccountDataEvent } from '../../types/matrix/accountData';
import { getAccountData, setAccountData } from '../utils/accountData';
// Generic, concurrency-safe store for a single global account-data event that
// holds a mutable collection (a list or a record). It is the shared engine
// behind useBookmarks / useReminders / useUserNotes.
//
// Why a module-scoped singleton (per store, per client):
//
// These hooks are mounted by many components at once (e.g. useBookmarks() lives
// on every message row). A per-instance snapshot + write queue would only
// serialize writes within a single instance, so two edits issued from different
// instances before the server echo lands would each compute from a stale
// snapshot and clobber each other — setAccountData replaces the whole content
// with no server-side merge. We therefore keep ONE shared `latest` snapshot and
// ONE write queue per store, keyed off the active MatrixClient, and serialize
// every write through that queue (the lost-update fix). Do not weaken this.
// How the differing payload shapes are parameterized:
// - `read` maps the raw account-data content (C | undefined) to the in-memory
// value T (e.g. `content?.bookmarks ?? []`, or `content ?? {}` for notes).
// - `write` maps the in-memory value T back to the content object to persist
// (e.g. `{ bookmarks: value }`, or just `value` for the flat notes record).
export type AccountDataListStoreConfig<T, C> = {
eventType: AccountDataEvent | string;
read: (content: C | undefined) => T;
write: (value: T) => object;
};
export type AccountDataListStore<T> = {
/** React hook: subscribe to the store and return the latest value. */
useValue: (mx: MatrixClient) => T;
/** Read the current shared snapshot without subscribing. */
getLatest: (mx: MatrixClient) => T;
/**
* Serialize a write. `compute` receives the latest snapshot and returns the
* next value; writes run one at a time so none is computed from stale data.
*/
enqueueWrite: (mx: MatrixClient, compute: (current: T) => T) => Promise<void>;
};
export function createAccountDataListStore<T, C = unknown>({
eventType,
read,
write,
}: AccountDataListStoreConfig<T, C>): AccountDataListStore<T> {
type ModuleState = {
mx: MatrixClient;
latest: T;
writeQueue: Promise<unknown>;
listeners: Set<(value: T) => void>;
onAccountData: ClientEventHandlerMap[ClientEvent.AccountData];
};
let moduleState: ModuleState | null = null;
const readLatest = (mx: MatrixClient): T => read(getAccountData<C>(mx, eventType));
// 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): ModuleState {
if (moduleState && moduleState.mx === mx) {
return moduleState;
}
if (moduleState) {
moduleState.mx.removeListener(ClientEvent.AccountData, moduleState.onAccountData);
}
const state: ModuleState = {
mx,
latest: readLatest(mx),
writeQueue: Promise.resolve(),
listeners: new Set(),
// Reassigned below once `state` is captured.
onAccountData: () => undefined,
};
state.onAccountData = (evt) => {
if (evt.getType() === eventType) {
const value = read(evt.getContent() as C);
state.latest = value;
state.listeners.forEach((listener) => listener(value));
}
};
mx.on(ClientEvent.AccountData, state.onAccountData);
moduleState = state;
return state;
}
function enqueueWrite(mx: MatrixClient, compute: (current: T) => T): 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, eventType, write(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;
}
function useValue(mx: MatrixClient): T {
const [value, setValue] = useState<T>(() => 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);
setValue(state.latest);
state.listeners.add(setValue);
return () => {
state.listeners.delete(setValue);
};
}, [mx]);
return value;
}
const getLatest = (mx: MatrixClient): T => ensureModuleState(mx).latest;
return { useValue, getLatest, enqueueWrite };
}