diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index fc15a8dd5c..fed840e763 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -147,6 +147,7 @@ export class ClineProvider private taskCreationCallback: (task: Task) => void private taskEventListeners: WeakMap void>> = new WeakMap() private currentWorkspacePath: string | undefined + private _disposed = false private recentTasksCache?: string[] private pendingOperations: Map = new Map() @@ -577,6 +578,11 @@ export class ClineProvider } async dispose() { + if (this._disposed) { + return + } + + this._disposed = true this.log("Disposing ClineProvider...") // Clear all tasks from the stack. @@ -1080,7 +1086,15 @@ export class ClineProvider } public async postMessageToWebview(message: ExtensionMessage) { - await this.view?.webview.postMessage(message) + if (this._disposed) { + return + } + + try { + await this.view?.webview.postMessage(message) + } catch { + // View disposed, drop message silently + } } private async getHMRHtmlContent(webview: vscode.Webview): Promise { diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 4bad630ed5..4c69746be3 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -327,6 +327,7 @@ vi.mock("@roo-code/cloud", () => ({ get instance() { return { isAuthenticated: vi.fn().mockReturnValue(false), + off: vi.fn(), } }, }, @@ -602,6 +603,43 @@ describe("ClineProvider", () => { expect(mockPostMessage).toHaveBeenCalledWith(message) }) + test("postMessageToWebview does not throw when webview is disposed", async () => { + await provider.resolveWebviewView(mockWebviewView) + + // Simulate postMessage throwing after webview disposal + mockPostMessage.mockRejectedValueOnce(new Error("Webview is disposed")) + + const message: ExtensionMessage = { type: "action", action: "chatButtonClicked" } + + // Should not throw + await expect(provider.postMessageToWebview(message)).resolves.toBeUndefined() + }) + + test("postMessageToWebview skips postMessage after dispose", async () => { + await provider.resolveWebviewView(mockWebviewView) + + await provider.dispose() + mockPostMessage.mockClear() + + const message: ExtensionMessage = { type: "action", action: "chatButtonClicked" } + await provider.postMessageToWebview(message) + + expect(mockPostMessage).not.toHaveBeenCalled() + }) + + test("dispose is idempotent — second call is a no-op", async () => { + await provider.resolveWebviewView(mockWebviewView) + + await provider.dispose() + await provider.dispose() + + // dispose body runs only once: log "Disposing ClineProvider..." appears once + const disposeCalls = (mockOutputChannel.appendLine as ReturnType).mock.calls.filter( + ([msg]) => typeof msg === "string" && msg.includes("Disposing ClineProvider..."), + ) + expect(disposeCalls).toHaveLength(1) + }) + test("handles webviewDidLaunch message", async () => { await provider.resolveWebviewView(mockWebviewView)