diff --git a/src/app/state/room/roomToParents.test.ts b/src/app/state/room/roomToParents.test.ts index e820c7766..75b8f3b5e 100644 --- a/src/app/state/room/roomToParents.test.ts +++ b/src/app/state/room/roomToParents.test.ts @@ -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 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); +}); diff --git a/src/app/state/room/roomToParents.ts b/src/app/state/room/roomToParents.ts index 4aa721bd2..90af22086 100644 --- a/src/app/state/room/roomToParents.ts +++ b/src/app/state/room/roomToParents.ts @@ -31,6 +31,11 @@ export type RoomToParentsAction = | { type: 'DELETE'; roomId: string; + } + | { + type: 'UNLINK'; + parent: string; + child: string; }; const baseRoomToParents = atom(new Map()); @@ -63,6 +68,23 @@ export const roomToParentsAtom = atom 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 }); } } }