From cb35ee67109c37a21fc84bf17db95c3942a7d5f5 Mon Sep 17 00:00:00 2001 From: Jason Matthew Suhari Date: Thu, 16 Apr 2026 03:41:50 +0800 Subject: [PATCH] fix(cli): clean up slash command IDE listeners (#24397) Co-authored-by: Tommaso Sciortino --- .../ui/hooks/slashCommandProcessor.test.tsx | 70 +++++++++++++++++++ .../cli/src/ui/hooks/slashCommandProcessor.ts | 13 ++-- 2 files changed, 78 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx b/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx index ec4aa00677..3e521a6627 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx @@ -858,11 +858,81 @@ describe('useSlashCommandProcessor', () => { }); describe('Lifecycle', () => { + it('removes the IDE status listener on unmount after async initialization', async () => { + let resolveIdeClient: + | ((client: { + addStatusChangeListener: (listener: () => void) => void; + removeStatusChangeListener: (listener: () => void) => void; + }) => void) + | undefined; + const addStatusChangeListener = vi.fn(); + const removeStatusChangeListener = vi.fn(); + + mockIdeClientGetInstance.mockImplementation( + () => + new Promise((resolve) => { + resolveIdeClient = resolve; + }), + ); + + const result = await setupProcessorHook(); + + await act(async () => { + resolveIdeClient?.({ + addStatusChangeListener, + removeStatusChangeListener, + }); + }); + + result.unmount(); + unmountHook = undefined; + + expect(addStatusChangeListener).toHaveBeenCalledTimes(1); + expect(removeStatusChangeListener).toHaveBeenCalledTimes(1); + expect(removeStatusChangeListener).toHaveBeenCalledWith( + addStatusChangeListener.mock.calls[0]?.[0], + ); + }); + + it('does not register an IDE status listener if unmounted before async initialization resolves', async () => { + let resolveIdeClient: + | ((client: { + addStatusChangeListener: (listener: () => void) => void; + removeStatusChangeListener: (listener: () => void) => void; + }) => void) + | undefined; + const addStatusChangeListener = vi.fn(); + const removeStatusChangeListener = vi.fn(); + + mockIdeClientGetInstance.mockImplementation( + () => + new Promise((resolve) => { + resolveIdeClient = resolve; + }), + ); + + const result = await setupProcessorHook(); + + result.unmount(); + unmountHook = undefined; + + await act(async () => { + resolveIdeClient?.({ + addStatusChangeListener, + removeStatusChangeListener, + }); + }); + + expect(addStatusChangeListener).not.toHaveBeenCalled(); + expect(removeStatusChangeListener).not.toHaveBeenCalled(); + }); + it('should abort command loading when the hook unmounts', async () => { const abortSpy = vi.spyOn(AbortController.prototype, 'abort'); const { unmount } = await setupProcessorHook(); unmount(); + unmountHook = undefined; expect(abortSpy).toHaveBeenCalledTimes(1); }); diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index f55503ad25..20de86002c 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -281,10 +281,16 @@ export const useSlashCommandProcessor = ( const listener = () => { reloadCommands(); }; + let isActive = true; + let activeIdeClient: IdeClient | undefined; // eslint-disable-next-line @typescript-eslint/no-floating-promises (async () => { const ideClient = await IdeClient.getInstance(); + if (!isActive) { + return; + } + activeIdeClient = ideClient; ideClient.addStatusChangeListener(listener); })(); @@ -307,11 +313,8 @@ export const useSlashCommandProcessor = ( coreEvents.on('extensionsStopping', extensionEventListener); return () => { - // eslint-disable-next-line @typescript-eslint/no-floating-promises - (async () => { - const ideClient = await IdeClient.getInstance(); - ideClient.removeStatusChangeListener(listener); - })(); + isActive = false; + activeIdeClient?.removeStatusChangeListener(listener); removeMCPStatusChangeListener(listener); coreEvents.off('extensionsStarting', extensionEventListener); coreEvents.off('extensionsStopping', extensionEventListener);