From 99ab0ea2de2404e695ce3010aa2a0ce40c594710 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 30 Aug 2026 04:59:36 -0700 Subject: [PATCH 1/2] Add tests for doc/snapshot-listener-only rooms surviving teardown destroyRoomIfEmpty's emptiness check only looks at clientIdByPrincipal and listeners, so a room kept alive only by a subscribeDocUpdates or subscribeSnapshots listener is destroyed and silently recreated on the next empty join/leave cycle, permanently orphaning that listener. --- packages/presence/src/room-registry.test.ts | 38 +++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/packages/presence/src/room-registry.test.ts b/packages/presence/src/room-registry.test.ts index 41f7ea96f..fc7609671 100644 --- a/packages/presence/src/room-registry.test.ts +++ b/packages/presence/src/room-registry.test.ts @@ -315,6 +315,44 @@ describe("createPresenceRoomRegistry: doc sync", () => { expect(seen).toEqual([]); }); + + test("a room kept alive only by a doc-update listener survives an otherwise-empty join/leave cycle", () => { + const registry = createPresenceRoomRegistry(); + const docUpdates: string[] = []; + registry.subscribeDocUpdates(docKey, (_update, author) => + docUpdates.push(author), + ); + + registry.join(docKey, state("prn_alice")); + registry.leave(docKey, "prn_alice"); // no presence `subscribe` listener attached + + // The room must still be alive — proven by a doc update still + // reaching the listener registered before the join/leave cycle, + // rather than the room having been silently torn down and recreated + // out from under it. + const alice = clientDoc(); + alice.getText("content").insert(0, "still here"); + registry.applyDocUpdate(docKey, Y.encodeStateAsUpdate(alice), "prn_alice2"); + + expect(docUpdates).toEqual(["prn_alice2"]); + }); + + test("a room kept alive only by a snapshot listener survives an otherwise-empty join/leave cycle", () => { + const registry = createPresenceRoomRegistry(); + const notifications: number[] = []; + registry.subscribeSnapshots(docKey, (info) => + notifications.push(info.version), + ); + + registry.join(docKey, state("prn_alice")); + registry.leave(docKey, "prn_alice"); + + // Proven the same way: the room surviving is what lets a later + // `notifySnapshot` still reach the listener. + registry.notifySnapshot(docKey, { version: 7, savedAt: 1 }); + + expect(notifications).toEqual([7]); + }); }); describe("createPresenceRoomRegistry: deferred destroy", () => { From a9adb98da43bc493acc1ead6efd5e4e80d4631b9 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 30 Aug 2026 04:59:46 -0700 Subject: [PATCH 2/2] Count doc/snapshot listeners toward a room's emptiness destroyRoomIfEmpty now treats a room as non-empty while it still has a subscribeDocUpdates or subscribeSnapshots listener, not just presence members and subscribe() listeners, and both of those subscriptions' teardown paths now attempt cleanup themselves so a room kept alive only by one of them still gets torn down once that listener goes away. --- packages/presence/src/room-registry.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/presence/src/room-registry.ts b/packages/presence/src/room-registry.ts index 2775936c1..8af84096b 100644 --- a/packages/presence/src/room-registry.ts +++ b/packages/presence/src/room-registry.ts @@ -277,7 +277,12 @@ export function createPresenceRoomRegistry(): PresenceRoomRegistry { } function isRoomEmpty(room: Room): boolean { - return room.clientIdByPrincipal.size === 0 && room.listeners.size === 0; + return ( + room.clientIdByPrincipal.size === 0 && + room.listeners.size === 0 && + room.docListeners.size === 0 && + room.snapshotListeners.size === 0 + ); } function destroyRoomIfEmpty(key: PresenceRoomKey, room: Room): void { @@ -443,6 +448,7 @@ export function createPresenceRoomRegistry(): PresenceRoomRegistry { room.docListeners.add(listener); return () => { room.docListeners.delete(listener); + destroyRoomIfEmpty(key, room); }; }, @@ -471,6 +477,7 @@ export function createPresenceRoomRegistry(): PresenceRoomRegistry { room.snapshotListeners.add(listener); return () => { room.snapshotListeners.delete(listener); + destroyRoomIfEmpty(key, room); }; }, };