diff --git a/src/modules/GroupChannel/components/TypingIndicator.tsx b/src/modules/GroupChannel/components/TypingIndicator.tsx index 0200e4023..47c3c4b84 100644 --- a/src/modules/GroupChannel/components/TypingIndicator.tsx +++ b/src/modules/GroupChannel/components/TypingIndicator.tsx @@ -40,13 +40,12 @@ export const TypingIndicator = ({ channelUrl }: TypingIndicatorProps) => { const { state } = useSendbird(); const sb = state?.stores?.sdkStore?.sdk; const logger = state?.config?.logger; - const [handlerId, setHandlerId] = useState(uuidv4()); const [typingMembers, setTypingMembers] = useState([]); useEffect(() => { + let handlerId: string | undefined; if (sb?.groupChannel?.addGroupChannelHandler) { - sb.groupChannel.removeGroupChannelHandler(handlerId); - const newHandlerId = uuidv4(); + handlerId = uuidv4(); const handler = new GroupChannelHandler({ onTypingStatusUpdated: (groupChannel) => { // there is a possible warning in here - setState called after unmount @@ -57,13 +56,12 @@ export const TypingIndicator = ({ channelUrl }: TypingIndicatorProps) => { } }, }); - sb.groupChannel.addGroupChannelHandler(newHandlerId, handler); - setHandlerId(newHandlerId); + sb.groupChannel.addGroupChannelHandler(handlerId, handler); } return () => { setTypingMembers([]); - if (sb?.groupChannel?.removeGroupChannelHandler) { + if (handlerId && sb?.groupChannel?.removeGroupChannelHandler) { sb.groupChannel.removeGroupChannelHandler(handlerId); } }; diff --git a/src/modules/GroupChannel/components/__test__/TypingIndicator.spec.tsx b/src/modules/GroupChannel/components/__test__/TypingIndicator.spec.tsx new file mode 100644 index 000000000..41e41d458 --- /dev/null +++ b/src/modules/GroupChannel/components/__test__/TypingIndicator.spec.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { render } from '@testing-library/react'; + +import { TypingIndicator } from '../TypingIndicator'; + +const { mockAddGroupChannelHandler, mockRemoveGroupChannelHandler, mockState } = vi.hoisted(() => { + const mockAddGroupChannelHandler = vi.fn(); + const mockRemoveGroupChannelHandler = vi.fn(); + const mockState = { + stores: { + sdkStore: { + sdk: { + groupChannel: { + addGroupChannelHandler: mockAddGroupChannelHandler, + removeGroupChannelHandler: mockRemoveGroupChannelHandler, + }, + }, + initialized: true, + }, + }, + config: { + logger: { info: vi.fn(), warning: vi.fn(), error: vi.fn() }, + }, + }; + return { mockAddGroupChannelHandler, mockRemoveGroupChannelHandler, mockState }; +}); + +vi.mock('../../../../lib/Sendbird/context/hooks/useSendbird', () => ({ + __esModule: true, + default: vi.fn(() => ({ state: mockState })), +})); + +describe('GroupChannel/TypingIndicator group channel handler lifecycle (CLNP-8774)', () => { + beforeEach(() => { + mockAddGroupChannelHandler.mockClear(); + mockRemoveGroupChannelHandler.mockClear(); + }); + + it('removes the exact handler id it registered when unmounted, so the handler does not leak', () => { + const { unmount } = render(); + + expect(mockAddGroupChannelHandler).toHaveBeenCalledTimes(1); + const registeredId = mockAddGroupChannelHandler.mock.calls[0][0]; + expect(registeredId).toBeTruthy(); + + unmount(); + + expect(mockRemoveGroupChannelHandler).toHaveBeenCalledWith(registeredId); + }); + + it('removes every registered handler across a channel switch followed by unmount (no leak)', () => { + const { rerender, unmount } = render(); + rerender(); + + expect(mockAddGroupChannelHandler).toHaveBeenCalledTimes(2); + const firstId = mockAddGroupChannelHandler.mock.calls[0][0]; + const secondId = mockAddGroupChannelHandler.mock.calls[1][0]; + expect(firstId).not.toBe(secondId); + + unmount(); + + expect(mockRemoveGroupChannelHandler).toHaveBeenCalledWith(firstId); + expect(mockRemoveGroupChannelHandler).toHaveBeenCalledWith(secondId); + }); +});