When a single m.space.child was removed (unlinking child C from space P), the
roomToParents reducer fired the whole-room DELETE action, which wiped C's
entire parent set, stripped C as a parent from every other room, and orphaned
C's own descendants until a full resync. So removing C from space A also
dropped C's other parent B, and C's children lost C.
Add a targeted UNLINK {parent, child} action that removes only that one
parent->child edge and prunes the child entry only when its parent set
empties (matching the map's build-time invariant that zero-parent rooms have
no entry). Point the invalid-child branch of handleStateChange at it; DELETE
is unchanged for genuine room leave/delete. Unit-tested (keeps other parents,
prunes on last parent, does NOT orphan descendants, unknown pair no-op).
Verified correct + consumer-safe by two review passes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
150 lines
4.8 KiB
TypeScript
150 lines
4.8 KiB
TypeScript
import { produce } from 'immer';
|
|
import { atom, useSetAtom } from 'jotai';
|
|
import {
|
|
ClientEvent,
|
|
MatrixClient,
|
|
MatrixEvent,
|
|
Room,
|
|
RoomEvent,
|
|
RoomStateEvent,
|
|
} from 'matrix-js-sdk';
|
|
import { useEffect } from 'react';
|
|
import { Membership, RoomToParents, StateEvent } from '../../../types/matrix/room';
|
|
import {
|
|
getRoomToParents,
|
|
getSpaceChildren,
|
|
isSpace,
|
|
isValidChild,
|
|
mapParentWithChildren,
|
|
} from '../../utils/room';
|
|
|
|
export type RoomToParentsAction =
|
|
| {
|
|
type: 'INITIALIZE';
|
|
roomToParents: RoomToParents;
|
|
}
|
|
| {
|
|
type: 'PUT';
|
|
parent: string;
|
|
children: string[];
|
|
}
|
|
| {
|
|
type: 'DELETE';
|
|
roomId: string;
|
|
}
|
|
| {
|
|
type: 'UNLINK';
|
|
parent: string;
|
|
child: string;
|
|
};
|
|
|
|
const baseRoomToParents = atom<RoomToParents>(new Map());
|
|
export const roomToParentsAtom = atom<RoomToParents, [RoomToParentsAction], undefined>(
|
|
(get) => get(baseRoomToParents),
|
|
(get, set, action) => {
|
|
if (action.type === 'INITIALIZE') {
|
|
set(baseRoomToParents, action.roomToParents);
|
|
return;
|
|
}
|
|
if (action.type === 'PUT') {
|
|
set(
|
|
baseRoomToParents,
|
|
produce(get(baseRoomToParents), (draftRoomToParents) => {
|
|
mapParentWithChildren(draftRoomToParents, action.parent, action.children);
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
if (action.type === 'DELETE') {
|
|
set(
|
|
baseRoomToParents,
|
|
produce(get(baseRoomToParents), (draftRoomToParents) => {
|
|
const noParentRooms: string[] = [];
|
|
draftRoomToParents.delete(action.roomId);
|
|
draftRoomToParents.forEach((parents, child) => {
|
|
parents.delete(action.roomId);
|
|
if (parents.size === 0) noParentRooms.push(child);
|
|
});
|
|
noParentRooms.forEach((room) => draftRoomToParents.delete(room));
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
if (action.type === 'UNLINK') {
|
|
// Remove ONE parent from ONE child (a single `m.space.child` was removed).
|
|
// Unlike DELETE (used when a room is left/deleted), this must NOT touch the
|
|
// child's other parents, nor strip the child as a parent elsewhere, nor
|
|
// orphan the child's own descendants. Only prune the child's entry if this
|
|
// was its last parent.
|
|
set(
|
|
baseRoomToParents,
|
|
produce(get(baseRoomToParents), (draftRoomToParents) => {
|
|
const parents = draftRoomToParents.get(action.child);
|
|
if (!parents) return;
|
|
parents.delete(action.parent);
|
|
if (parents.size === 0) draftRoomToParents.delete(action.child);
|
|
}),
|
|
);
|
|
}
|
|
},
|
|
);
|
|
|
|
export const useBindRoomToParentsAtom = (
|
|
mx: MatrixClient,
|
|
roomToParents: typeof roomToParentsAtom,
|
|
) => {
|
|
const setRoomToParents = useSetAtom(roomToParents);
|
|
|
|
useEffect(() => {
|
|
setRoomToParents({ type: 'INITIALIZE', roomToParents: getRoomToParents(mx) });
|
|
|
|
const handleAddRoom = (room: Room) => {
|
|
if (isSpace(room) && room.getMyMembership() !== Membership.Invite) {
|
|
setRoomToParents({ type: 'PUT', parent: room.roomId, children: getSpaceChildren(room) });
|
|
}
|
|
};
|
|
|
|
const handleMembershipChange = (room: Room, membership: string) => {
|
|
if (isSpace(room) && room.getMyMembership() === Membership.Leave) {
|
|
setRoomToParents({ type: 'DELETE', roomId: room.roomId });
|
|
return;
|
|
}
|
|
if (isSpace(room) && membership === Membership.Join) {
|
|
setRoomToParents({ type: 'PUT', parent: room.roomId, children: getSpaceChildren(room) });
|
|
}
|
|
};
|
|
|
|
const handleStateChange = (mEvent: MatrixEvent) => {
|
|
if (mEvent.getType() === StateEvent.SpaceChild) {
|
|
const childId = mEvent.getStateKey();
|
|
const roomId = mEvent.getRoomId();
|
|
if (childId && roomId) {
|
|
if (isValidChild(mEvent)) {
|
|
setRoomToParents({ type: 'PUT', parent: roomId, children: [childId] });
|
|
} else {
|
|
// A single m.space.child was removed: unlink only THIS parent→child
|
|
// edge. DELETE here wiped the child's other parents and orphaned its
|
|
// descendants until a full resync (COR-1).
|
|
setRoomToParents({ type: 'UNLINK', parent: roomId, child: childId });
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleDeleteRoom = (roomId: string) => {
|
|
setRoomToParents({ type: 'DELETE', roomId });
|
|
};
|
|
|
|
mx.on(ClientEvent.Room, handleAddRoom);
|
|
mx.on(RoomEvent.MyMembership, handleMembershipChange);
|
|
mx.on(RoomStateEvent.Events, handleStateChange);
|
|
mx.on(ClientEvent.DeleteRoom, handleDeleteRoom);
|
|
return () => {
|
|
mx.removeListener(ClientEvent.Room, handleAddRoom);
|
|
mx.removeListener(RoomEvent.MyMembership, handleMembershipChange);
|
|
mx.removeListener(RoomStateEvent.Events, handleStateChange);
|
|
mx.removeListener(ClientEvent.DeleteRoom, handleDeleteRoom);
|
|
};
|
|
}, [mx, setRoomToParents]);
|
|
};
|