fix(spaces): unlink one space-child edge instead of over-deleting (COR-1)

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>
This commit is contained in:
2026-07-18 17:04:14 -04:00
co-authored by Claude Opus 4.8
parent 1f80d1d129
commit fd3b8b421e
2 changed files with 72 additions and 1 deletions
+26 -1
View File
@@ -31,6 +31,11 @@ export type RoomToParentsAction =
| {
type: 'DELETE';
roomId: string;
}
| {
type: 'UNLINK';
parent: string;
child: string;
};
const baseRoomToParents = atom<RoomToParents>(new Map());
@@ -63,6 +68,23 @@ export const roomToParentsAtom = atom<RoomToParents, [RoomToParentsAction], unde
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);
}),
);
}
},
);
@@ -100,7 +122,10 @@ export const useBindRoomToParentsAtom = (
if (isValidChild(mEvent)) {
setRoomToParents({ type: 'PUT', parent: roomId, children: [childId] });
} else {
setRoomToParents({ type: 'DELETE', roomId: childId });
// 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 });
}
}
}