Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ export class ClineProvider
private taskCreationCallback: (task: Task) => void
private taskEventListeners: WeakMap<Task, Array<() => void>> = new WeakMap()
private currentWorkspacePath: string | undefined
private _disposed = false

private recentTasksCache?: string[]
private pendingOperations: Map<string, PendingEditOperation> = new Map()
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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<string> {
Expand Down
38 changes: 38 additions & 0 deletions src/core/webview/__tests__/ClineProvider.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,7 @@ vi.mock("@roo-code/cloud", () => ({
get instance() {
return {
isAuthenticated: vi.fn().mockReturnValue(false),
off: vi.fn(),
}
},
},
Expand Down Expand Up @@ -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<typeof vi.fn>).mock.calls.filter(
([msg]) => typeof msg === "string" && msg.includes("Disposing ClineProvider..."),
)
expect(disposeCalls).toHaveLength(1)
})

test("handles webviewDidLaunch message", async () => {
await provider.resolveWebviewView(mockWebviewView)

Expand Down
Loading