Skip to content

Commit 4eebab1

Browse files
committed
fix(webview): handle disposal during resolve and test stale listener guards
1 parent bb9c57e commit 4eebab1

2 files changed

Lines changed: 126 additions & 32 deletions

File tree

‎src/core/webview/ClineProvider.ts‎

Lines changed: 25 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1048,6 +1048,31 @@ export class ClineProvider
10481048
this.view = webviewView
10491049
const inTabMode = "onDidChangeViewState" in webviewView
10501050

1051+
// Register disposal before awaiting HTML or state so closing the view during
1052+
// either load invalidates this resolve and prevents later listener installation.
1053+
webviewView.onDidDispose(
1054+
async () => {
1055+
// A view that is no longer current must not clear the current
1056+
// view's resources or dispose the provider.
1057+
if (webviewView !== this.view) {
1058+
return
1059+
}
1060+
1061+
if (inTabMode) {
1062+
this.log("Disposing ClineProvider instance for tab view")
1063+
await this.dispose()
1064+
} else {
1065+
this.log("Clearing webview resources for sidebar view")
1066+
this.view = undefined
1067+
this.clearWebviewResources()
1068+
}
1069+
},
1070+
null,
1071+
// View-scoped, like the other view subscriptions: disposed both when
1072+
// the view is disposed and when a different view replaces it.
1073+
this.webviewDisposables,
1074+
)
1075+
10511076
if (inTabMode) {
10521077
setPanel(webviewView, "tab")
10531078
} else if ("onDidChangeVisibility" in webviewView) {
@@ -1159,31 +1184,6 @@ export class ClineProvider
11591184
this.webviewDisposables.push(visibilityDisposable)
11601185
}
11611186

1162-
// Listen for when the view is disposed
1163-
// This happens when the user closes the view or when the view is closed programmatically
1164-
webviewView.onDidDispose(
1165-
async () => {
1166-
// A view that is no longer current must not clear the current
1167-
// view's resources or dispose the provider.
1168-
if (webviewView !== this.view) {
1169-
return
1170-
}
1171-
1172-
if (inTabMode) {
1173-
this.log("Disposing ClineProvider instance for tab view")
1174-
await this.dispose()
1175-
} else {
1176-
this.log("Clearing webview resources for sidebar view")
1177-
this.view = undefined
1178-
this.clearWebviewResources()
1179-
}
1180-
},
1181-
null,
1182-
// View-scoped, like the other view subscriptions: disposed both when
1183-
// the view is disposed and when a different view replaces it.
1184-
this.webviewDisposables,
1185-
)
1186-
11871187
// Listen for when color changes
11881188
const configDisposable = vscode.workspace.onDidChangeConfiguration(async (e) => {
11891189
// Ignore configuration refreshes scoped to a view that is no longer current.

‎src/core/webview/__tests__/ClineProvider.stale-view.spec.ts‎

Lines changed: 101 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -147,11 +147,12 @@ function makeDeferred<T>() {
147147
const flushAsync = () => new Promise<void>((resolve) => setTimeout(resolve, 0))
148148

149149
/** An emitter-backed vscode.Event that also reports how many listeners are installed. */
150-
function makeViewEvent<T>() {
150+
function makeViewEvent<T>(retainDisposedListeners = false) {
151151
const listeners = new Set<(value: T) => unknown>()
152152
const event: vscode.Event<T> = (listener, _thisArgs, disposables) => {
153153
listeners.add(listener)
154-
const subscription = { dispose: () => listeners.delete(listener) }
154+
// Retention models callbacks already queued when their subscription is disposed.
155+
const subscription = { dispose: () => !retainDisposedListeners && listeners.delete(listener) }
155156
// Real VS Code events push the subscription into the provided array.
156157
disposables?.push(subscription)
157158
return subscription
@@ -163,10 +164,10 @@ function makeViewEvent<T>() {
163164
}
164165
}
165166

166-
function createSidebarView() {
167-
const messages = makeViewEvent<WebviewMessage>()
168-
const visibility = makeViewEvent<void>()
169-
const disposed = makeViewEvent<void>()
167+
function createSidebarView(retainDisposedListeners = false) {
168+
const messages = makeViewEvent<WebviewMessage>(retainDisposedListeners)
169+
const visibility = makeViewEvent<void>(retainDisposedListeners)
170+
const disposed = makeViewEvent<void>(retainDisposedListeners)
170171
const postMessage = vi.fn<(message: ExtensionMessage) => Promise<boolean>>().mockResolvedValue(true)
171172
const view: vscode.WebviewView = {
172173
viewType: ClineProvider.sideBarId,
@@ -186,6 +187,25 @@ function createSidebarView() {
186187
return { view, messages, visibility, disposed, postMessage }
187188
}
188189

190+
function createPanelView(retainDisposedListeners = false) {
191+
const sidebar = createSidebarView(retainDisposedListeners)
192+
const viewState = makeViewEvent<vscode.WebviewPanelOnDidChangeViewStateEvent>(retainDisposedListeners)
193+
const view: vscode.WebviewPanel = {
194+
webview: sidebar.view.webview,
195+
viewType: "test-panel",
196+
viewColumn: undefined,
197+
title: "Test panel",
198+
options: {},
199+
visible: true,
200+
active: true,
201+
onDidDispose: sidebar.disposed.event,
202+
onDidChangeViewState: viewState.event,
203+
reveal: vi.fn(),
204+
dispose: vi.fn(),
205+
}
206+
return { ...sidebar, view, viewState }
207+
}
208+
189209
describe("ClineProvider - stale view lifecycle", () => {
190210
let provider: ClineProvider
191211
let mockContext: vscode.ExtensionContext
@@ -380,7 +400,7 @@ describe("ClineProvider - stale view lifecycle", () => {
380400
})
381401

382402
test("events from a replaced view are ignored and do not disturb the current view", async () => {
383-
const first = createSidebarView()
403+
const first = createSidebarView(true)
384404
const second = createSidebarView()
385405
await provider.resolveWebviewView(first.view)
386406
await provider.resolveWebviewView(second.view)
@@ -390,15 +410,89 @@ describe("ClineProvider - stale view lifecycle", () => {
390410
expect(vi.mocked(webviewMessageHandler)).not.toHaveBeenCalled()
391411

392412
first.visibility.fire()
413+
await flushAsync()
393414
expect(first.postMessage).not.toHaveBeenCalled()
415+
expect(second.postMessage).not.toHaveBeenCalled()
394416

395417
// A stale dispose must not clear the current view's listeners.
396418
first.disposed.fire()
419+
expect(provider["view"]).toBe(second.view)
397420
second.visibility.fire()
398421
await flushAsync()
399422
expect(second.postMessage).toHaveBeenCalledWith({ type: "action", action: "didBecomeVisible" })
400423
})
401424

425+
test("a queued configuration callback from a replaced view sends no theme", async () => {
426+
const first = createSidebarView()
427+
const second = createSidebarView()
428+
await provider.resolveWebviewView(first.view)
429+
const listener = vi.mocked(vscode.workspace.onDidChangeConfiguration).mock.calls.at(-1)![0]
430+
await provider.resolveWebviewView(second.view)
431+
432+
await listener({ affectsConfiguration: () => true })
433+
434+
expect(first.postMessage).not.toHaveBeenCalled()
435+
expect(second.postMessage).not.toHaveBeenCalled()
436+
})
437+
438+
test.each(["html", "state"] as const)("disposing a view during %s loading stops its resolve", async (stage) => {
439+
const first = createSidebarView()
440+
const html = makeDeferred<string>()
441+
const state = makeDeferred<ProviderState>()
442+
const realState = await provider.getState()
443+
provider["getHtmlContent"] = vi
444+
.fn()
445+
.mockImplementation(() => (stage === "html" ? html.promise : Promise.resolve("document")))
446+
const stateSpy = vi.spyOn(provider, "getState").mockImplementation(() => state.promise)
447+
const resolving = provider.resolveWebviewView(first.view)
448+
await flushAsync()
449+
450+
first.disposed.fire()
451+
expect(provider["view"]).toBeUndefined()
452+
html.resolve("document")
453+
state.resolve(realState)
454+
await resolving
455+
stateSpy.mockRestore()
456+
457+
expect(first.view.webview.html).toBe(stage === "html" ? "" : "document")
458+
expect(first.messages.listenerCount()).toBe(0)
459+
expect(first.visibility.listenerCount()).toBe(0)
460+
expect(first.disposed.listenerCount()).toBe(0)
461+
})
462+
463+
test("panel replacement disposes the focus tracker and ignores queued panel events", async () => {
464+
const first = createPanelView(true)
465+
const second = createPanelView()
466+
const disposeFocus = vi.fn()
467+
const focusSpy = vi
468+
.spyOn(provider.webviewFocusTracker, "init")
469+
.mockReturnValueOnce({ dispose: disposeFocus })
470+
.mockReturnValue({ dispose: vi.fn() })
471+
const disposeSpy = vi.spyOn(provider, "dispose")
472+
await provider.resolveWebviewView(first.view)
473+
expect(disposeFocus).not.toHaveBeenCalled()
474+
await provider.resolveWebviewView(second.view)
475+
expect(disposeFocus).toHaveBeenCalledTimes(1)
476+
477+
first.viewState.fire({ webviewPanel: first.view })
478+
first.disposed.fire()
479+
await flushAsync()
480+
expect(first.postMessage).not.toHaveBeenCalled()
481+
expect(second.postMessage).not.toHaveBeenCalled()
482+
expect(disposeSpy).not.toHaveBeenCalled()
483+
expect(provider["view"]).toBe(second.view)
484+
485+
second.viewState.fire({ webviewPanel: second.view })
486+
await flushAsync()
487+
expect(second.postMessage).toHaveBeenCalledWith({ type: "action", action: "didBecomeVisible" })
488+
second.disposed.fire()
489+
await flushAsync()
490+
expect(disposeSpy).toHaveBeenCalledTimes(1)
491+
expect(ClineProvider.getAllInstances()).not.toContain(provider)
492+
focusSpy.mockRestore()
493+
disposeSpy.mockRestore()
494+
})
495+
402496
test("disposing the sidebar view clears the current view and its subscriptions", async () => {
403497
const first = createSidebarView()
404498
await provider.resolveWebviewView(first.view)

0 commit comments

Comments
 (0)