diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 28a42913f3..32b82feaaa 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -219,7 +219,6 @@ export class ClineProvider public static readonly sideBarId = `${Package.name}.SidebarProvider` public static readonly tabPanelId = `${Package.name}.TabPanelProvider` private static activeInstances: Set = new Set() - private disposables: vscode.Disposable[] = [] private webviewDisposables: vscode.Disposable[] = [] private pendingThemeFixtureProbes = new Map< string, @@ -837,6 +836,15 @@ export class ClineProvider } } + /** + * True when an in-flight resolve no longer belongs to the provider's current + * view because the view was disposed or replaced (or the provider itself was + * disposed) while the resolve was awaiting HTML or state hydration. + */ + private isStaleViewResolve(webviewView: vscode.WebviewView | vscode.WebviewPanel): boolean { + return this._disposed || this.view !== webviewView + } + /** Drain one task's memoized cleanup without preventing the remaining provider shutdown work. */ private async drainTaskDisposal(task: Task): Promise { try { @@ -893,14 +901,6 @@ export class ClineProvider CloudService.instance.off("settings-updated", this.handleCloudSettingsUpdate) } - while (this.disposables.length) { - const x = this.disposables.pop() - - if (x) { - x.dispose() - } - } - this._workspaceTracker?.dispose() this._workspaceTracker = undefined await this.mcpHub?.unregisterClient() @@ -1030,9 +1030,40 @@ export class ClineProvider } async resolveWebviewView(webviewView: vscode.WebviewView | vscode.WebviewPanel) { + // A different view replaces the current one: dispose its subscriptions so + // the replaced view can no longer dispatch messages or refresh provider state. + if (this.view !== webviewView) { + this.clearWebviewResources() + } + this.view = webviewView const inTabMode = "onDidChangeViewState" in webviewView + // Register disposal before awaiting HTML or state so closing the view during + // either load invalidates this resolve and prevents later listener installation. + webviewView.onDidDispose( + async () => { + // A view that is no longer current must not clear the current + // view's resources or dispose the provider. + if (webviewView !== this.view) { + return + } + + if (inTabMode) { + this.log("Disposing ClineProvider instance for tab view") + await this.dispose() + } else { + this.log("Clearing webview resources for sidebar view") + this.view = undefined + this.clearWebviewResources() + } + }, + null, + // View-scoped, like the other view subscriptions: disposed both when + // the view is disposed and when a different view replaces it. + this.webviewDisposables, + ) + if (inTabMode) { setPanel(webviewView, "tab") } else if ("onDidChangeVisibility" in webviewView) { @@ -1052,12 +1083,20 @@ export class ClineProvider localResourceRoots: resourceRoots, } - webviewView.webview.html = + const html = this.contextProxy.extensionMode === vscode.ExtensionMode.Development && process.env.ROO_CODE_THEME_FIXTURE_PROBE !== "1" ? await this.getHMRHtmlContent(webviewView.webview) : await this.getHtmlContent(webviewView.webview) + // The HTML load above can finish after the view was disposed or replaced. + // A stale resolve must not assign HTML to a view the provider no longer owns. + if (this.isStaleViewResolve(webviewView)) { + return + } + + webviewView.webview.html = html + // Initialize out-of-scope variables that need to receive persistent // global state values. await this.getState().then( @@ -1088,9 +1127,15 @@ export class ClineProvider }, ) + // State hydration can also finish after the view was disposed or replaced. + // A stale resolve must not install listeners on a view the provider no longer owns. + if (this.isStaleViewResolve(webviewView)) { + return + } + // Sets up an event listener to listen for messages passed from the webview view context // and executes code based on the message that is received. - this.setWebviewMessageListener(webviewView.webview) + this.setWebviewMessageListener(webviewView) this.webviewDisposables.push(this.webviewFocusTracker.init(this, webviewView)) // Listen for when the panel becomes visible. @@ -1099,6 +1144,11 @@ export class ClineProvider // WebviewView and WebviewPanel have all the same properties except // for this visibility listener panel. const viewStateDisposable = webviewView.onDidChangeViewState(() => { + // Ignore events from a view that is no longer the current view. + if (webviewView !== this.view) { + return + } + if (this.view?.visible) { void this.postMessageToWebview({ type: "action", action: "didBecomeVisible" }) } else { @@ -1110,6 +1160,11 @@ export class ClineProvider } else if ("onDidChangeVisibility" in webviewView) { // sidebar const visibilityDisposable = webviewView.onDidChangeVisibility(() => { + // Ignore events from a view that is no longer the current view. + if (webviewView !== this.view) { + return + } + if (this.view?.visible) { void this.postMessageToWebview({ type: "action", action: "didBecomeVisible" }) } else { @@ -1120,24 +1175,13 @@ export class ClineProvider this.webviewDisposables.push(visibilityDisposable) } - // Listen for when the view is disposed - // This happens when the user closes the view or when the view is closed programmatically - webviewView.onDidDispose( - async () => { - if (inTabMode) { - this.log("Disposing ClineProvider instance for tab view") - await this.dispose() - } else { - this.log("Clearing webview resources for sidebar view") - this.clearWebviewResources() - } - }, - null, - this.disposables, - ) - // Listen for when color changes const configDisposable = vscode.workspace.onDidChangeConfiguration(async (e) => { + // Ignore configuration refreshes scoped to a view that is no longer current. + if (webviewView !== this.view) { + return + } + if (e && e.affectsConfiguration("workbench.colorTheme")) { // Sends latest theme name to webview await this.postMessageToWebview({ type: "theme", text: JSON.stringify(await getTheme()) }) @@ -1704,13 +1748,19 @@ export class ClineProvider * Sets up an event listener to listen for messages passed from the webview context and * executes code based on the message that is received. * - * @param webview A reference to the extension webview + * @param webviewView The view whose webview messages are listened to */ - private setWebviewMessageListener(webview: vscode.Webview) { - const onReceiveMessage = async (message: WebviewMessage) => - webviewMessageHandler(this, message, this.marketplaceManager) + private setWebviewMessageListener(webviewView: vscode.WebviewView | vscode.WebviewPanel) { + const onReceiveMessage = async (message: WebviewMessage) => { + // Ignore messages from a view that is no longer the current view. + if (webviewView !== this.view) { + return + } + + await webviewMessageHandler(this, message, this.marketplaceManager) + } - const messageDisposable = webview.onDidReceiveMessage(onReceiveMessage) + const messageDisposable = webviewView.webview.onDidReceiveMessage(onReceiveMessage) this.webviewDisposables.push(messageDisposable) } diff --git a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts index 3121fe1046..c2aec90dcd 100644 --- a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts @@ -224,8 +224,7 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { asWebviewUri: vi.fn(), }, visible: true, - onDidDispose: vi.fn().mockImplementation((callback) => { - callback() + onDidDispose: vi.fn().mockImplementation(() => { return { dispose: vi.fn() } }), onDidChangeVisibility: vi.fn().mockImplementation(() => { diff --git a/src/core/webview/__tests__/ClineProvider.lockApiConfig.spec.ts b/src/core/webview/__tests__/ClineProvider.lockApiConfig.spec.ts index 6472b77749..fa042f5b86 100644 --- a/src/core/webview/__tests__/ClineProvider.lockApiConfig.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.lockApiConfig.spec.ts @@ -316,8 +316,7 @@ describe("ClineProvider - Lock API Config Across Modes", () => { cspSource: "vscode-webview://test-csp-source", }, visible: true, - onDidDispose: vi.fn().mockImplementation((callback) => { - callback() + onDidDispose: vi.fn().mockImplementation(() => { return { dispose: vi.fn() } }), onDidChangeVisibility: vi.fn().mockImplementation(() => { diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 93543435e2..779313d385 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -536,8 +536,7 @@ describe("ClineProvider", () => { cspSource: "vscode-webview://test-csp-source", }, visible: true, - onDidDispose: vi.fn().mockImplementation((callback) => { - callback() + onDidDispose: vi.fn().mockImplementation(() => { return { dispose: vi.fn() } }), onDidChangeVisibility: vi.fn().mockImplementation(() => { @@ -4512,8 +4511,7 @@ describe("ClineProvider - Router Models", () => { asWebviewUri: vi.fn(), }, visible: true, - onDidDispose: vi.fn().mockImplementation((callback) => { - callback() + onDidDispose: vi.fn().mockImplementation(() => { return { dispose: vi.fn() } }), onDidChangeVisibility: vi.fn().mockImplementation(() => { @@ -4911,8 +4909,7 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { asWebviewUri: vi.fn(), }, visible: true, - onDidDispose: vi.fn().mockImplementation((callback) => { - callback() + onDidDispose: vi.fn().mockImplementation(() => { return { dispose: vi.fn() } }), onDidChangeVisibility: vi.fn().mockImplementation(() => { diff --git a/src/core/webview/__tests__/ClineProvider.stale-view.spec.ts b/src/core/webview/__tests__/ClineProvider.stale-view.spec.ts new file mode 100644 index 0000000000..b570ceb4bf --- /dev/null +++ b/src/core/webview/__tests__/ClineProvider.stale-view.spec.ts @@ -0,0 +1,533 @@ +import { WebviewFocusTracker } from "../WebviewFocusTracker" +// pnpm --filter roo-cline test core/webview/__tests__/ClineProvider.stale-view.spec.ts + +import * as vscode from "vscode" +import { makeCompositeDisposable } from "../../../test-utils/vscode" + +import { TelemetryService } from "@roo-code/telemetry" +import type { ExtensionMessage, WebviewMessage } from "@roo-code/types" + +import { ContextProxy } from "../../config/ContextProxy" +import { ClineProvider } from "../ClineProvider" +import { webviewMessageHandler } from "../webviewMessageHandler" + +// Mock setup +vi.mock("fs/promises", () => ({ + mkdir: vi.fn().mockResolvedValue(undefined), + writeFile: vi.fn().mockResolvedValue(undefined), + readFile: vi.fn().mockResolvedValue(""), + unlink: vi.fn().mockResolvedValue(undefined), + rmdir: vi.fn().mockResolvedValue(undefined), +})) + +vi.mock("../../../utils/storage", () => ({ + getSettingsDirectoryPath: vi.fn().mockResolvedValue("/test/settings/path"), + getTaskDirectoryPath: vi.fn().mockResolvedValue("/test/task/path"), + getGlobalStoragePath: vi.fn().mockResolvedValue("/test/storage/path"), +})) + +vi.mock("p-wait-for", () => ({ + __esModule: true, + default: vi.fn().mockResolvedValue(undefined), +})) + +vi.mock("delay", () => { + const delayFn = (_ms: number) => Promise.resolve() + delayFn.createDelay = delayFn + delayFn.reject = delayFn + delayFn.range = delayFn + return { default: delayFn } +}) + +vi.mock("vscode", () => ({ + ExtensionContext: vi.fn(), + OutputChannel: vi.fn(), + WebviewView: vi.fn(), + Disposable: { from: (...subscriptions: vscode.Disposable[]) => makeCompositeDisposable(...subscriptions) }, + Uri: { + joinPath: vi.fn(), + file: vi.fn(), + }, + commands: { + executeCommand: vi.fn().mockResolvedValue(undefined), + }, + window: { + showInformationMessage: vi.fn(), + showWarningMessage: vi.fn(), + showErrorMessage: vi.fn(), + onDidChangeActiveTextEditor: vi.fn(() => ({ dispose: vi.fn() })), + }, + workspace: { + getConfiguration: vi.fn().mockReturnValue({ + get: vi.fn().mockReturnValue([]), + update: vi.fn(), + }), + onDidChangeConfiguration: vi.fn().mockImplementation(() => { + return { + dispose: vi.fn(), + } + }), + }, + env: { + uriScheme: "vscode", + language: "en", + appName: "Visual Studio Code", + }, + extensions: { + all: [], + }, + ExtensionMode: { + Production: 1, + Development: 2, + Test: 3, + }, + version: "1.85.0", +})) + +vi.mock("../../../utils/tts", () => ({ + setTtsEnabled: vi.fn(), + setTtsSpeed: vi.fn(), +})) + +vi.mock("../../../api", () => ({ + buildApiHandler: vi.fn(), +})) + +vi.mock("../../../integrations/workspace/WorkspaceTracker", () => { + return { + default: vi.fn().mockImplementation(function () { + return { + initializeFilePaths: vi.fn(), + dispose: vi.fn(), + } + }), + } +}) + +vi.mock("../../task/Task", () => ({ + Task: vi.fn().mockImplementation(() => ({ + taskId: "mock-task-id", + instanceId: "mock-instance-id", + abortTask: vi.fn().mockResolvedValue(undefined), + emit: vi.fn(), + })), +})) + +vi.mock("@roo-code/cloud", () => ({ + CloudService: { + hasInstance: vi.fn().mockReturnValue(true), + get instance() { + return { + isAuthenticated: vi.fn().mockReturnValue(false), + off: vi.fn(), + } + }, + }, + getRooCodeApiUrl: vi.fn().mockReturnValue("https://app.roocode.com"), +})) + +vi.mock("../../../integrations/theme/getTheme", () => ({ + getTheme: vi.fn().mockResolvedValue({ kind: "dark" }), +})) + +vi.mock("../webviewMessageHandler", () => ({ + webviewMessageHandler: vi.fn().mockResolvedValue(undefined), +})) + +type ProviderState = Awaited> + +function makeDeferred() { + let resolve!: (value: T) => void + const promise = new Promise((res) => { + resolve = res + }) + return { promise, resolve } +} + +const flushAsync = () => new Promise((resolve) => setTimeout(resolve, 0)) + +/** An emitter-backed vscode.Event that also reports how many listeners are installed. */ +function makeViewEvent(retainDisposedListeners = false) { + const listeners = new Set<(value: T) => unknown>() + const event: vscode.Event = (listener, _thisArgs, disposables) => { + listeners.add(listener) + // Retention models callbacks already queued when their subscription is disposed. + const subscription = { dispose: () => !retainDisposedListeners && listeners.delete(listener) } + // Real VS Code events push the subscription into the provided array. + disposables?.push(subscription) + return subscription + } + return { + event, + fire: (value: T) => [...listeners].forEach((listener) => listener(value)), + listenerCount: () => listeners.size, + } +} + +function createSidebarView(retainDisposedListeners = false) { + const messages = makeViewEvent(retainDisposedListeners) + const visibility = makeViewEvent(retainDisposedListeners) + const disposed = makeViewEvent(retainDisposedListeners) + const postMessage = vi.fn<(message: ExtensionMessage) => Promise>().mockResolvedValue(true) + const view: vscode.WebviewView = { + viewType: ClineProvider.sideBarId, + visible: true, + webview: { + html: "", + options: {}, + cspSource: "vscode-webview://test-csp-source", + asWebviewUri: (uri) => uri, + postMessage, + onDidReceiveMessage: messages.event, + }, + onDidChangeVisibility: visibility.event, + onDidDispose: disposed.event, + show: vi.fn(), + } + return { view, messages, visibility, disposed, postMessage } +} + +function createPanelView(retainDisposedListeners = false) { + const sidebar = createSidebarView(retainDisposedListeners) + const viewState = makeViewEvent(retainDisposedListeners) + const view: vscode.WebviewPanel = { + webview: sidebar.view.webview, + viewType: "test-panel", + viewColumn: undefined, + title: "Test panel", + options: {}, + visible: true, + active: true, + onDidDispose: sidebar.disposed.event, + onDidChangeViewState: viewState.event, + reveal: vi.fn(), + dispose: vi.fn(), + } + return { ...sidebar, view, viewState } +} + +describe("ClineProvider - stale view lifecycle", () => { + let provider: ClineProvider + let mockContext: vscode.ExtensionContext + let mockOutputChannel: vscode.OutputChannel + + beforeEach(() => { + vi.clearAllMocks() + + if (!TelemetryService.hasInstance()) { + TelemetryService.createInstance([]) + } + + const globalState: Record = { + mode: "code", + currentApiConfigName: "test-config", + } + + const secrets: Record = {} + + // Structural double providing only the storage members ClineProvider and ContextProxy read. + mockContext = { + extensionPath: "/test/path", + extensionUri: { fsPath: "/test/path" }, + globalState: { + get: vi.fn().mockImplementation((key: string) => globalState[key]), + update: vi.fn().mockImplementation((key: string, value: unknown) => { + globalState[key] = value + return Promise.resolve() + }), + keys: vi.fn().mockImplementation(() => Object.keys(globalState)), + }, + secrets: { + get: vi.fn().mockImplementation((key: string) => secrets[key]), + store: vi.fn().mockImplementation((key: string, value: string | undefined) => { + secrets[key] = value + return Promise.resolve() + }), + delete: vi.fn().mockImplementation((key: string) => { + delete secrets[key] + return Promise.resolve() + }), + }, + workspaceState: { + get: vi.fn().mockReturnValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + keys: vi.fn().mockReturnValue([]), + }, + subscriptions: [], + extension: { + packageJSON: { version: "1.0.0" }, + }, + globalStorageUri: { + fsPath: "/test/storage/path", + }, + } as unknown as vscode.ExtensionContext + + // Structural double providing only the logging members ClineProvider calls. + mockOutputChannel = { + appendLine: vi.fn(), + clear: vi.fn(), + dispose: vi.fn(), + } as unknown as vscode.OutputChannel + + provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + }) + + afterEach(async () => { + await provider.dispose() + }) + + describe("stale-resolve guards", () => { + test("a resolve that finishes after the view was replaced assigns no HTML and installs no listeners", async () => { + const first = createSidebarView() + const second = createSidebarView() + const htmlDeferred = makeDeferred() + provider["getHtmlContent"] = vi + .fn<(webview: vscode.Webview) => Promise>() + .mockImplementationOnce(() => htmlDeferred.promise) + .mockImplementation(() => Promise.resolve(`current view`)) + + const staleResolve = provider.resolveWebviewView(first.view) + await provider.resolveWebviewView(second.view) + + htmlDeferred.resolve(`stale view`) + await staleResolve + + expect(first.view.webview.html).toBe("") + expect(second.view.webview.html).toContain("current view") + expect(first.messages.listenerCount()).toBe(0) + expect(first.visibility.listenerCount()).toBe(0) + expect(first.disposed.listenerCount()).toBe(0) + }) + + test("a resolve that finishes after the provider was disposed assigns no HTML and installs no listeners", async () => { + const first = createSidebarView() + const htmlDeferred = makeDeferred() + provider["getHtmlContent"] = vi + .fn<(webview: vscode.Webview) => Promise>() + .mockImplementation(() => htmlDeferred.promise) + + const staleResolve = provider.resolveWebviewView(first.view) + await provider.dispose() + + htmlDeferred.resolve(`too late`) + await staleResolve + + expect(first.view.webview.html).toBe("") + expect(first.messages.listenerCount()).toBe(0) + expect(first.visibility.listenerCount()).toBe(0) + expect(first.disposed.listenerCount()).toBe(0) + }) + + test("a resolve that finishes after the view was replaced during state hydration installs no listeners", async () => { + const first = createSidebarView() + const second = createSidebarView() + // Resolve HTML immediately: the deferral under test is the resolve-level + // await this.getState() that runs after the HTML assignment. + provider["getHtmlContent"] = vi + .fn<(webview: vscode.Webview) => Promise>() + .mockResolvedValue(`sidebar`) + const stateDeferred = makeDeferred() + const realGetState = provider.getState.bind(provider) + const stateSpy = vi + .spyOn(provider, "getState") + .mockImplementationOnce(() => stateDeferred.promise) + .mockImplementation(() => realGetState()) + + const staleResolve = provider.resolveWebviewView(first.view) + // Let the first resolve run past the HTML assignment and park inside getState(). + await flushAsync() + + await provider.resolveWebviewView(second.view) + stateDeferred.resolve(await realGetState()) + await staleResolve + stateSpy.mockRestore() + + // HTML was assigned before the replacement, so the replaced view keeps its document... + expect(first.view.webview.html).toContain("") + // ...but the stale resolve installed no listeners on it. + expect(first.messages.listenerCount()).toBe(0) + expect(first.visibility.listenerCount()).toBe(0) + expect(first.disposed.listenerCount()).toBe(0) + expect(second.messages.listenerCount()).toBeGreaterThan(0) + }) + + test("a resolve that finishes after the provider was disposed during state hydration installs no listeners", async () => { + const first = createSidebarView() + // Resolve HTML immediately so the deferred await this.getState() is the + // resolve-level hydration, not the one inside getHtmlContent. + provider["getHtmlContent"] = vi + .fn<(webview: vscode.Webview) => Promise>() + .mockResolvedValue(`sidebar`) + const stateDeferred = makeDeferred() + const realGetState = provider.getState.bind(provider) + const stateSpy = vi.spyOn(provider, "getState").mockImplementation(() => stateDeferred.promise) + + const staleResolve = provider.resolveWebviewView(first.view) + await flushAsync() + + await provider.dispose() + stateDeferred.resolve(await realGetState()) + await staleResolve + stateSpy.mockRestore() + + expect(first.view.webview.html).toContain("") + expect(first.messages.listenerCount()).toBe(0) + expect(first.visibility.listenerCount()).toBe(0) + expect(first.disposed.listenerCount()).toBe(0) + }) + }) + + test("a different view replacing the current one disposes the replaced view's subscriptions", async () => { + const first = createSidebarView() + const second = createSidebarView() + await provider.resolveWebviewView(first.view) + + expect(first.messages.listenerCount()).toBeGreaterThan(0) + expect(first.visibility.listenerCount()).toBeGreaterThan(0) + expect(first.disposed.listenerCount()).toBeGreaterThan(0) + + await provider.resolveWebviewView(second.view) + + expect(first.messages.listenerCount()).toBe(0) + expect(first.visibility.listenerCount()).toBe(0) + expect(first.disposed.listenerCount()).toBe(0) + }) + + test("events from a replaced view are ignored and do not disturb the current view", async () => { + const first = createSidebarView(true) + const second = createSidebarView() + await provider.resolveWebviewView(first.view) + await provider.resolveWebviewView(second.view) + + const message: WebviewMessage = { type: "webviewDidFocus" } + first.messages.fire(message) + expect(vi.mocked(webviewMessageHandler)).not.toHaveBeenCalled() + + first.visibility.fire() + await flushAsync() + expect(first.postMessage).not.toHaveBeenCalled() + expect(second.postMessage).not.toHaveBeenCalled() + + // A stale dispose must not clear the current view's listeners. + first.disposed.fire() + expect(provider["view"]).toBe(second.view) + second.visibility.fire() + await flushAsync() + expect(second.postMessage).toHaveBeenCalledWith({ type: "action", action: "didBecomeVisible" }) + }) + + test("a queued configuration callback from a replaced view sends no theme", async () => { + const first = createSidebarView() + const second = createSidebarView() + await provider.resolveWebviewView(first.view) + const listener = vi.mocked(vscode.workspace.onDidChangeConfiguration).mock.calls.at(-1)![0] + await provider.resolveWebviewView(second.view) + + await listener({ affectsConfiguration: () => true }) + + expect(first.postMessage).not.toHaveBeenCalled() + expect(second.postMessage).not.toHaveBeenCalled() + }) + + test.each(["html", "state"] as const)("disposing a view during %s loading stops its resolve", async (stage) => { + const first = createSidebarView() + const html = makeDeferred() + const state = makeDeferred() + const realState = await provider.getState() + provider["getHtmlContent"] = vi + .fn() + .mockImplementation(() => (stage === "html" ? html.promise : Promise.resolve("document"))) + const stateSpy = vi.spyOn(provider, "getState").mockImplementation(() => state.promise) + const resolving = provider.resolveWebviewView(first.view) + await flushAsync() + + first.disposed.fire() + expect(provider["view"]).toBeUndefined() + html.resolve("document") + state.resolve(realState) + await resolving + stateSpy.mockRestore() + + expect(first.view.webview.html).toBe(stage === "html" ? "" : "document") + expect(first.messages.listenerCount()).toBe(0) + expect(first.visibility.listenerCount()).toBe(0) + expect(first.disposed.listenerCount()).toBe(0) + }) + + test("panel replacement disposes the focus tracker and ignores queued panel events", async () => { + const first = createPanelView(true) + const second = createPanelView() + const disposeFocus = vi.fn() + const focusSpy = vi + .spyOn(provider.webviewFocusTracker, "init") + .mockReturnValueOnce({ dispose: disposeFocus }) + .mockReturnValue({ dispose: vi.fn() }) + const disposeSpy = vi.spyOn(provider, "dispose") + await provider.resolveWebviewView(first.view) + expect(disposeFocus).not.toHaveBeenCalled() + await provider.resolveWebviewView(second.view) + expect(disposeFocus).toHaveBeenCalledTimes(1) + + first.viewState.fire({ webviewPanel: first.view }) + first.disposed.fire() + await flushAsync() + expect(first.postMessage).not.toHaveBeenCalled() + expect(second.postMessage).not.toHaveBeenCalled() + expect(disposeSpy).not.toHaveBeenCalled() + expect(provider["view"]).toBe(second.view) + + second.viewState.fire({ webviewPanel: second.view }) + await flushAsync() + expect(second.postMessage).toHaveBeenCalledWith({ type: "action", action: "didBecomeVisible" }) + second.disposed.fire() + await flushAsync() + expect(disposeSpy).toHaveBeenCalledTimes(1) + expect(ClineProvider.getAllInstances()).not.toContain(provider) + focusSpy.mockRestore() + disposeSpy.mockRestore() + }) + + test("disposing the sidebar view clears the current view and its subscriptions", async () => { + const first = createSidebarView() + await provider.resolveWebviewView(first.view) + expect(provider.isViewVisible).toBe(true) + + first.disposed.fire() + + expect(provider["view"]).toBeUndefined() + expect(provider.isViewVisible).toBe(false) + expect(first.messages.listenerCount()).toBe(0) + expect(first.visibility.listenerCount()).toBe(0) + expect(first.disposed.listenerCount()).toBe(0) + // Only the view's resources are cleared; the sidebar provider instance itself remains. + expect(ClineProvider.getAllInstances()).toContain(provider) + }) + + test("the current view keeps dispatching messages, visibility, and theme refreshes", async () => { + const first = createSidebarView() + await provider.resolveWebviewView(first.view) + + const message: WebviewMessage = { type: "webviewDidFocus" } + first.messages.fire(message) + await flushAsync() + expect(vi.mocked(webviewMessageHandler)).toHaveBeenCalledWith(provider, message, provider["marketplaceManager"]) + + first.visibility.fire() + await flushAsync() + expect(first.postMessage).toHaveBeenCalledWith({ type: "action", action: "didBecomeVisible" }) + + const onDidChangeConfiguration = vi.mocked(vscode.workspace.onDidChangeConfiguration) + const configListener = onDidChangeConfiguration.mock.calls.at(-1)![0] + const colorThemeChange: vscode.ConfigurationChangeEvent = { affectsConfiguration: () => true } + await configListener(colorThemeChange) + expect(first.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ type: "theme", text: JSON.stringify({ kind: "dark" }) }), + ) + }) +}) diff --git a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts index 22aa4d7e97..47fcfd11f0 100644 --- a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts @@ -295,8 +295,7 @@ describe("ClineProvider - Sticky Mode", () => { cspSource: "vscode-webview://test-csp-source", }, visible: true, - onDidDispose: vi.fn().mockImplementation((callback) => { - callback() + onDidDispose: vi.fn().mockImplementation(() => { return { dispose: vi.fn() } }), onDidChangeVisibility: vi.fn().mockImplementation(() => { diff --git a/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts b/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts index ccf243f1e1..178415aa6d 100644 --- a/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts @@ -294,8 +294,7 @@ describe("ClineProvider - Sticky Provider Profile", () => { cspSource: "vscode-webview://test-csp-source", }, visible: true, - onDidDispose: vi.fn().mockImplementation((callback) => { - callback() + onDidDispose: vi.fn().mockImplementation(() => { return { dispose: vi.fn() } }), onDidChangeVisibility: vi.fn().mockImplementation(() => { diff --git a/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts b/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts index 30d635df4b..7b6223e2e2 100644 --- a/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts @@ -343,8 +343,7 @@ describe("ClineProvider Task History Synchronization", () => { cspSource: "vscode-webview://test-csp-source", }, visible: true, - onDidDispose: vi.fn().mockImplementation((callback) => { - callback() + onDidDispose: vi.fn().mockImplementation(() => { return { dispose: vi.fn() } }), onDidChangeVisibility: vi.fn().mockImplementation(() => {