From 14301018abad23310a4097ee013fc504e70f1c1c Mon Sep 17 00:00:00 2001 From: martincupela Date: Mon, 2 Nov 2026 10:53:28 +0100 Subject: [PATCH 1/3] fix: notify channel state subscribers on watcher, ban and member removal events Watcher events (user.watching.start/stop, user.updated), ban events (user.banned/unbanned) and the watchers loaded by a channel query wrote into the current watchers/members objects, so subscribers comparing by reference never saw the change. They now publish new objects. member.removed for the current user now also resets membership. Refs REACT-1324 --- src/channel.ts | 47 +++++++------ test/unit/channel.test.js | 136 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 163 insertions(+), 20 deletions(-) diff --git a/src/channel.ts b/src/channel.ts index d913c267f..e1a691e4e 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -2359,13 +2359,18 @@ export class Channel extends ChannelApi { case 'user.watching.start': case 'user.updated': if (event.user?.id) { - channelState.watchers[event.user.id] = event.user; + channelState.watchers = { + ...channelState.watchers, + [event.user.id]: event.user, + }; channel.getClient().state.updateUserReference(event.user, channel.cid); } break; case 'user.watching.stop': - if (event.user?.id) { - delete channelState.watchers[event.user.id]; + if (event.user?.id && channelState.watchers[event.user.id]) { + const newWatchers = { ...channelState.watchers }; + delete newWatchers[event.user.id]; + channelState.watchers = newWatchers; } break; case 'message.deleted': @@ -2574,7 +2579,9 @@ export class Channel extends ChannelApi { channelState.members = newMembers; - // TODO?: unset membership + if (event.user.id === this.getClient().userId) { + channelState.membership = {} as ChannelMemberResponse; + } } break; case 'notification.mark_unread': { @@ -2660,23 +2667,21 @@ export class Channel extends ChannelApi { break; } case 'user.banned': + case 'user.unbanned': { if (!event.user?.id) break; - channelState.members[event.user.id] = { - ...(channelState.members[event.user.id] || {}), - shadow_banned: !!event.shadow, - banned: !event.shadow, - user: { ...(channelState.members[event.user.id]?.user || {}), ...event.user }, - }; - break; - case 'user.unbanned': - if (!event.user?.id) break; - channelState.members[event.user.id] = { - ...(channelState.members[event.user.id] || {}), - shadow_banned: false, - banned: false, - user: { ...(channelState.members[event.user.id]?.user || {}), ...event.user }, + const isBanned = event.type === 'user.banned'; + const member = channelState.members[event.user.id]; + channelState.members = { + ...channelState.members, + [event.user.id]: { + ...(member || {}), + shadow_banned: isBanned && !!event.shadow, + banned: isBanned && !event.shadow, + user: { ...(member?.user || {}), ...event.user }, + }, }; break; + } default: } @@ -2754,13 +2759,15 @@ export class Channel extends ChannelApi { if (state.watcher_count !== undefined) { this.state.watcher_count = state.watcher_count; } - if (state.watchers) { + if (state.watchers?.length) { + const watchers = { ...this.state.watchers }; for (const watcher of state.watchers) { if (watcher) { clientState.updateUserReference(watcher, this.cid); - this.state.watchers[watcher.id] = watcher; + watchers[watcher.id] = watcher; } } + this.state.watchers = watchers; } // Initialized to the latest message (or now, if empty) so nothing up to here counts as diff --git a/test/unit/channel.test.js b/test/unit/channel.test.js index daa48529c..181d3f54e 100644 --- a/test/unit/channel.test.js +++ b/test/unit/channel.test.js @@ -1524,6 +1524,120 @@ describe('Channel _handleChannelEvent', function () { expect(channel.data.member_count).to.equal(5); }); + + it('member.removed for the current user clears ChannelState.membership', () => { + const currentMember = generateMember({ user }); + const otherMember = generateMember({ user: otherUser }); + channel._handleChannelEvent({ type: 'member.added', user, member: currentMember }); + channel._handleChannelEvent({ + type: 'member.added', + user: otherUser, + member: otherMember, + }); + + channel._handleChannelEvent({ + type: 'member.removed', + user: otherUser, + member: otherMember, + }); + expect(channel.state.membership).to.deep.equal(currentMember); + + channel._handleChannelEvent({ + type: 'member.removed', + user, + member: currentMember, + }); + expect(channel.state.members).to.be.empty; + expect(channel.state.membership).to.be.empty; + }); + }); + + describe('watchers and members publish a new reference', () => { + const subscribeTo = (key) => { + const handler = vi.fn(); + channel.state.subscribeWithSelector((state) => ({ [key]: state[key] }), handler); + handler.mockClear(); + return handler; + }; + + it('notifies watchers subscribers on user.watching.start and user.watching.stop', () => { + const handler = subscribeTo('watchers'); + + channel._handleChannelEvent({ type: 'user.watching.start', user: otherUser }); + expect(handler).toHaveBeenCalledTimes(1); + expect(channel.state.watchers).to.deep.equal({ [otherUser.id]: otherUser }); + + channel._handleChannelEvent({ type: 'user.watching.stop', user: otherUser }); + expect(handler).toHaveBeenCalledTimes(2); + expect(channel.state.watchers).to.be.empty; + }); + + it('notifies watchers subscribers on user.updated for a watcher', () => { + channel._handleChannelEvent({ type: 'user.watching.start', user: otherUser }); + const handler = subscribeTo('watchers'); + const renamed = { ...otherUser, name: 'Renamed' }; + + channel._handleChannelEvent({ type: 'user.updated', user: renamed }); + + expect(handler).toHaveBeenCalledTimes(1); + expect(channel.state.watchers[otherUser.id]).to.equal(renamed); + }); + + it('does not notify watchers subscribers on user.watching.stop for a user who was not watching', () => { + const handler = subscribeTo('watchers'); + + channel._handleChannelEvent({ type: 'user.watching.stop', user: otherUser }); + + expect(handler).not.toHaveBeenCalled(); + }); + + it('notifies members subscribers on user.banned and user.unbanned', () => { + channel._handleChannelEvent({ + type: 'member.added', + user: otherUser, + member: generateMember({ user: otherUser }), + }); + const handler = subscribeTo('members'); + + channel._handleChannelEvent({ type: 'user.banned', user: otherUser }); + expect(handler).toHaveBeenCalledTimes(1); + expect(channel.state.members[otherUser.id].banned).to.be.true; + + channel._handleChannelEvent({ type: 'user.unbanned', user: otherUser }); + expect(handler).toHaveBeenCalledTimes(2); + expect(channel.state.members[otherUser.id].banned).to.be.false; + }); + + it('notifies membership subscribers when the current user is removed', () => { + channel._handleChannelEvent({ + type: 'member.added', + user, + member: generateMember({ user }), + }); + const handler = subscribeTo('membership'); + + channel._handleChannelEvent({ type: 'member.removed', user }); + + expect(handler).toHaveBeenCalledTimes(1); + }); + + it('does not notify membership subscribers when another member is removed', () => { + channel._handleChannelEvent({ + type: 'member.added', + user, + member: generateMember({ user }), + }); + channel._handleChannelEvent({ + type: 'member.added', + user: otherUser, + member: generateMember({ user: otherUser }), + }); + const handler = subscribeTo('membership'); + + channel._handleChannelEvent({ type: 'member.removed', user: otherUser }); + + expect(handler).not.toHaveBeenCalled(); + }); }); describe('message.new', () => { @@ -4000,6 +4114,28 @@ describe('Channel last_message_at', () => { }); describe('Channel _initializeState', () => { + it('publishes a new watchers reference, keeping watchers already in state', async () => { + const client = await getClientWithUser(); + const channel = client.channelManager.ensure({ type: 'messaging', id: uuidv4() }); + const alice = { id: 'alice' }; + const bob = { id: 'bob' }; + channel.state.watchers = { [alice.id]: alice }; + const previousWatchers = channel.state.watchers; + const handler = vi.fn(); + channel.state.subscribeWithSelector( + (state) => ({ watchers: state.watchers }), + handler, + ); + handler.mockClear(); + + channel._initializeState({ watchers: [bob] }); + + expect(handler).toHaveBeenCalledTimes(1); + expect(channel.state.watchers).to.not.equal(previousWatchers); + expect(channel.state.watchers).to.deep.equal({ [alice.id]: alice, [bob.id]: bob }); + expect(previousWatchers).to.deep.equal({ [alice.id]: alice }); + }); + it('should not keep members that have unwatched since last watch', async () => { const client = await getClientWithUser(); const channel = client.channelManager.ensure({ type: 'messaging', id: uuidv4() }); From deacf54bb75e25b8a5e9cb9bba6e02862f26e62c Mon Sep 17 00:00:00 2001 From: martincupela Date: Mon, 2 Nov 2026 10:53:33 +0100 Subject: [PATCH 2/3] docs: mark watchers-members-in-place follow-up as solved --- ai-docs/v10-followups.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ai-docs/v10-followups.md b/ai-docs/v10-followups.md index bf4c0b0a7..5d6cb6734 100644 --- a/ai-docs/v10-followups.md +++ b/ai-docs/v10-followups.md @@ -295,7 +295,7 @@ These are full items in [Dead code and leftovers](#dead-code); listed here so th -#### Watchers and members are mutated in place on the store-backed state +#### Watchers and members are mutated in place on the store-backed state [SOLVED 14301018] `bug` · confidence: high · pre-release: no · channel state @@ -303,6 +303,7 @@ These are full items in [Dead code and leftovers](#dead-code); listed here so th - **What:** `channelState.watchers[id] = user`, `delete channelState.watchers[id]` and `channelState.members[id] = {...}` write into the store's current object; no new reference is published. - **Evidence / scenario:** `useStateStore(channel.state, (s) => ({ watchers: s.watchers }))` does not re-render when another user starts watching or is banned; the shallow selector compare sees the same object. - **Suggested change:** Assign new objects (`channelState.watchers = { ...channelState.watchers, [id]: user }`, same for `members`) or use `partialNext`. +- **Also:** `member.removed` for the current user left `membership` set; it now resets it to the empty initial value. From 51ca3e031c212ffa494b313c46f7ea294b647667 Mon Sep 17 00:00:00 2001 From: martincupela Date: Mon, 2 Nov 2026 12:32:08 +0100 Subject: [PATCH 3/3] fix: read the removed member from event.member on member.removed The spec documents event.user on member.removed as the user who removed the member, so the removed user's id now comes from event.member and falls back to event.user. Refs REACT-1324 --- src/channel.ts | 13 +++++++++---- test/unit/channel.test.js | 28 ++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/src/channel.ts b/src/channel.ts index e1a691e4e..7cba9f326 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -2569,21 +2569,26 @@ export class Channel extends ChannelApi { } break; } - case 'member.removed': - if (event.user?.id) { + case 'member.removed': { + // The spec documents `event.user` as the user who removed the member, so the removed + // user is read from `event.member` first. + const removedUserId = + event.member?.user?.id ?? event.member?.user_id ?? event.user?.id; + if (removedUserId) { const newMembers = { ...channelState.members, }; - delete newMembers[event.user.id]; + delete newMembers[removedUserId]; channelState.members = newMembers; - if (event.user.id === this.getClient().userId) { + if (removedUserId === this.getClient().userId) { channelState.membership = {} as ChannelMemberResponse; } } break; + } case 'notification.mark_unread': { const ownMessage = event.user?.id === this.getClient().user?.id; if (!ownMessage || !event.user || event.last_read_at == null) break; diff --git a/test/unit/channel.test.js b/test/unit/channel.test.js index 181d3f54e..5bfd3d393 100644 --- a/test/unit/channel.test.js +++ b/test/unit/channel.test.js @@ -1550,6 +1550,34 @@ describe('Channel _handleChannelEvent', function () { expect(channel.state.members).to.be.empty; expect(channel.state.membership).to.be.empty; }); + + it('member.removed removes event.member, not an event.user who removed them', () => { + const currentMember = generateMember({ user }); + const otherMember = generateMember({ user: otherUser }); + channel._handleChannelEvent({ type: 'member.added', user, member: currentMember }); + channel._handleChannelEvent({ + type: 'member.added', + user: otherUser, + member: otherMember, + }); + + channel._handleChannelEvent({ type: 'member.removed', user, member: otherMember }); + + expect(channel.state.members).to.have.keys([user.id]); + expect(channel.state.membership).to.deep.equal(currentMember); + }); + + it('member.removed falls back to event.user without event.member', () => { + channel._handleChannelEvent({ + type: 'member.added', + user: otherUser, + member: generateMember({ user: otherUser }), + }); + + channel._handleChannelEvent({ type: 'member.removed', user: otherUser }); + + expect(channel.state.members).to.be.empty; + }); }); describe('watchers and members publish a new reference', () => {