fix(rooms): serialize per-room rename writes so back-to-back renames survive

Local room names did a read-modify-write of io.lotus.room_names against
the SDK's local cache, which is stale until the /sync echo, so a second
rename issued before the first echoed overwrote it. Route through
createAccountDataListStore like user notes. Unit-tested with a client
whose setAccountData does not update the local store.

Fixes #17

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
2026-09-12 14:48:35 -04:00
co-authored by Claude Opus 5
parent 2344c8273e
commit 7c52027afb
3 changed files with 148 additions and 69 deletions
+48 -55
View File
@@ -1,10 +1,10 @@
import { useCallback, useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types';
import { ClientEvent, MatrixEvent, Room, RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
import { Room, RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
import { StateEvent } from '../../types/matrix/room';
import { useStateEvent } from './useStateEvent';
import { useMatrixClient } from './useMatrixClient';
import { getAccountData } from '../utils/accountData';
import { createAccountDataListStore } from './createAccountDataListStore';
export const useRoomAvatar = (room: Room, dm?: boolean): string | undefined => {
const avatarEvent = useStateEvent(room, StateEvent.RoomAvatar);
@@ -40,79 +40,72 @@ export const LOCAL_ROOM_NAMES_KEY = 'io.lotus.room_names';
export type LocalRoomNamesContent = { rooms: Record<string, string> };
type LocalRoomNamesMap = Record<string, string>;
// Shared, concurrency-safe store. See createAccountDataListStore for why the
// snapshot + write queue must be module-scoped: setAccountData does not update
// the SDK's local cache (it only resolves once the /sync echo lands), so a
// plain read-modify-write against getAccountData can lose a rename that is
// still in flight when a second rename is issued (fixed: back-to-back renames
// of different rooms no longer clobber each other).
const roomNamesStore = createAccountDataListStore<LocalRoomNamesMap, LocalRoomNamesContent>({
eventType: LOCAL_ROOM_NAMES_KEY,
read: (content) =>
content && typeof content === 'object' && typeof content.rooms === 'object'
? content.rooms
: {},
write: (rooms) => ({ rooms }),
});
export function getLocalRoomNamesContent(
mx: ReturnType<typeof useMatrixClient>,
): LocalRoomNamesContent {
const raw: unknown = getAccountData<unknown>(mx, LOCAL_ROOM_NAMES_KEY);
if (
raw &&
typeof raw === 'object' &&
'rooms' in raw &&
// eslint-disable-next-line @typescript-eslint/no-explicit-any
typeof (raw as any).rooms === 'object'
) {
return raw as LocalRoomNamesContent;
}
return { rooms: {} };
return { rooms: roomNamesStore.getLatest(mx) };
}
/**
* Set (or clear, when `name` is empty) the local display name for a room.
* Routed through the shared write queue so back-to-back renames of different
* rooms are always computed from the latest snapshot instead of a stale one.
*/
export function setLocalRoomName(
mx: ReturnType<typeof useMatrixClient>,
roomId: string,
name: string,
): Promise<void> {
return roomNamesStore.enqueueWrite(mx, (current) => {
if (!name) {
const { [roomId]: _removed, ...rest } = current;
return rest;
}
return { ...current, [roomId]: name };
});
}
export const useLocalRoomName = (room: Room): string => {
const mx = useMatrixClient();
const getLocalName = useCallback((): string => {
const content = getLocalRoomNamesContent(mx);
return content.rooms[room.roomId] ?? room.name;
}, [mx, room]);
const [name, setName] = useState(getLocalName);
const localNames = roomNamesStore.useValue(mx);
const [name, setName] = useState(room.name);
useEffect(() => {
setName(getLocalName());
const handleAccountData = (event: MatrixEvent) => {
if (event.getType() !== LOCAL_ROOM_NAMES_KEY) return;
setName(getLocalName());
};
mx.on(ClientEvent.AccountData, handleAccountData);
setName(room.name);
const handleRoomNameChange: RoomEventHandlerMap[RoomEvent.Name] = () => {
setName(getLocalName());
setName(room.name);
};
room.on(RoomEvent.Name, handleRoomNameChange);
return () => {
mx.removeListener(ClientEvent.AccountData, handleAccountData);
room.removeListener(RoomEvent.Name, handleRoomNameChange);
};
}, [mx, room, getLocalName]);
}, [room]);
return name;
return localNames[room.roomId] ?? name;
};
export const useHasLocalRoomName = (roomId: string): boolean => {
const mx = useMatrixClient();
const check = useCallback((): boolean => {
const content = getLocalRoomNamesContent(mx);
return !!content.rooms[roomId];
}, [mx, roomId]);
const [hasLocal, setHasLocal] = useState(check);
useEffect(() => {
setHasLocal(check());
const handleAccountData = (event: MatrixEvent) => {
if (event.getType() !== LOCAL_ROOM_NAMES_KEY) return;
setHasLocal(check());
};
mx.on(ClientEvent.AccountData, handleAccountData);
return () => {
mx.removeListener(ClientEvent.AccountData, handleAccountData);
};
}, [mx, check]);
return hasLocal;
const localNames = roomNamesStore.useValue(mx);
return !!localNames[roomId];
};
export type RoomTopicContent = {