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
+46
View File
@@ -116,3 +116,49 @@ test('DELETE of an unknown room only prunes nothing', () => {
assert.deepEqual(parentsOf(store, '!c:s'), ['!p:s']);
assert.equal(get(store).size, 1);
});
test('UNLINK removes ONE parent but keeps the childs other parents', () => {
const store = createStore();
// !c is a child of both !a and !b.
store.set(roomToParentsAtom, { type: 'PUT', parent: '!a:s', children: ['!c:s'] });
store.set(roomToParentsAtom, { type: 'PUT', parent: '!b:s', children: ['!c:s'] });
store.set(roomToParentsAtom, { type: 'UNLINK', parent: '!a:s', child: '!c:s' });
// Only !a is dropped; !b remains. (DELETE would have wiped both.)
assert.deepEqual(parentsOf(store, '!c:s'), ['!b:s']);
});
test('UNLINK prunes the child only when it was the last parent', () => {
const store = createStore();
store.set(roomToParentsAtom, { type: 'PUT', parent: '!a:s', children: ['!c:s'] });
store.set(roomToParentsAtom, { type: 'UNLINK', parent: '!a:s', child: '!c:s' });
// !c had only !a; with that gone its entry is pruned.
assert.equal(get(store).has('!c:s'), false);
assert.equal(get(store).size, 0);
});
test('UNLINK does NOT orphan the childs own descendants', () => {
const store = createStore();
// !mid is a child of !top and a parent of !leaf.
store.set(roomToParentsAtom, { type: 'PUT', parent: '!top:s', children: ['!mid:s'] });
store.set(roomToParentsAtom, { type: 'PUT', parent: '!mid:s', children: ['!leaf:s'] });
// Unlink !mid from !top only (one m.space.child removed).
store.set(roomToParentsAtom, { type: 'UNLINK', parent: '!top:s', child: '!mid:s' });
// !mid lost its only parent -> its own entry is pruned...
assert.equal(get(store).has('!mid:s'), false);
// ...but !leaf MUST still have !mid as a parent (DELETE wrongly stripped this).
assert.deepEqual(parentsOf(store, '!leaf:s'), ['!mid:s']);
});
test('UNLINK of an unknown parent/child is a no-op', () => {
const store = createStore();
store.set(roomToParentsAtom, { type: 'PUT', parent: '!a:s', children: ['!c:s'] });
store.set(roomToParentsAtom, { type: 'UNLINK', parent: '!nope:s', child: '!c:s' });
assert.deepEqual(parentsOf(store, '!c:s'), ['!a:s']);
store.set(roomToParentsAtom, { type: 'UNLINK', parent: '!a:s', child: '!missing:s' });
assert.deepEqual(parentsOf(store, '!c:s'), ['!a:s']);
assert.equal(get(store).size, 1);
});
+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 });
}
}
}