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:
@@ -116,3 +116,49 @@ test('DELETE of an unknown room only prunes nothing', () => {
|
|||||||
assert.deepEqual(parentsOf(store, '!c:s'), ['!p:s']);
|
assert.deepEqual(parentsOf(store, '!c:s'), ['!p:s']);
|
||||||
assert.equal(get(store).size, 1);
|
assert.equal(get(store).size, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('UNLINK removes ONE parent but keeps the child’s 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 child’s 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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -31,6 +31,11 @@ export type RoomToParentsAction =
|
|||||||
| {
|
| {
|
||||||
type: 'DELETE';
|
type: 'DELETE';
|
||||||
roomId: string;
|
roomId: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'UNLINK';
|
||||||
|
parent: string;
|
||||||
|
child: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const baseRoomToParents = atom<RoomToParents>(new Map());
|
const baseRoomToParents = atom<RoomToParents>(new Map());
|
||||||
@@ -63,6 +68,23 @@ export const roomToParentsAtom = atom<RoomToParents, [RoomToParentsAction], unde
|
|||||||
noParentRooms.forEach((room) => draftRoomToParents.delete(room));
|
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)) {
|
if (isValidChild(mEvent)) {
|
||||||
setRoomToParents({ type: 'PUT', parent: roomId, children: [childId] });
|
setRoomToParents({ type: 'PUT', parent: roomId, children: [childId] });
|
||||||
} else {
|
} 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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user