Skip to content

Commit 3f51e9d

Browse files
zoomote2[bot]roomoteedelauna
authored
[Improve] Dispose view listeners and ignore stale views when the webview is replaced (#2213)
* fix(webview): dispose view listeners and ignore stale views when the webview is replaced * fix(webview): handle disposal during resolve and test stale listener guards * refactor(webview): remove unused provider disposables field and cleanup loop --------- Co-authored-by: Roomote <roomote@roomote.dev> Co-authored-by: Elliott de Launay <edelauna@gmail.com>
1 parent 8fb8046 commit 3f51e9d

8 files changed

Lines changed: 623 additions & 48 deletions

‎src/core/webview/ClineProvider.ts‎

Lines changed: 82 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,6 @@ export class ClineProvider
219219
public static readonly sideBarId = `${Package.name}.SidebarProvider`
220220
public static readonly tabPanelId = `${Package.name}.TabPanelProvider`
221221
private static activeInstances: Set<ClineProvider> = new Set()
222-
private disposables: vscode.Disposable[] = []
223222
private webviewDisposables: vscode.Disposable[] = []
224223
private pendingThemeFixtureProbes = new Map<
225224
string,
@@ -837,6 +836,15 @@ export class ClineProvider
837836
}
838837
}
839838

839+
/**
840+
* True when an in-flight resolve no longer belongs to the provider's current
841+
* view because the view was disposed or replaced (or the provider itself was
842+
* disposed) while the resolve was awaiting HTML or state hydration.
843+
*/
844+
private isStaleViewResolve(webviewView: vscode.WebviewView | vscode.WebviewPanel): boolean {
845+
return this._disposed || this.view !== webviewView
846+
}
847+
840848
/** Drain one task's memoized cleanup without preventing the remaining provider shutdown work. */
841849
private async drainTaskDisposal(task: Task): Promise<void> {
842850
try {
@@ -893,14 +901,6 @@ export class ClineProvider
893901
CloudService.instance.off("settings-updated", this.handleCloudSettingsUpdate)
894902
}
895903

896-
while (this.disposables.length) {
897-
const x = this.disposables.pop()
898-
899-
if (x) {
900-
x.dispose()
901-
}
902-
}
903-
904904
this._workspaceTracker?.dispose()
905905
this._workspaceTracker = undefined
906906
await this.mcpHub?.unregisterClient()
@@ -1030,9 +1030,40 @@ export class ClineProvider
10301030
}
10311031

10321032
async resolveWebviewView(webviewView: vscode.WebviewView | vscode.WebviewPanel) {
1033+
// A different view replaces the current one: dispose its subscriptions so
1034+
// the replaced view can no longer dispatch messages or refresh provider state.
1035+
if (this.view !== webviewView) {
1036+
this.clearWebviewResources()
1037+
}
1038+
10331039
this.view = webviewView
10341040
const inTabMode = "onDidChangeViewState" in webviewView
10351041

1042+
// Register disposal before awaiting HTML or state so closing the view during
1043+
// either load invalidates this resolve and prevents later listener installation.
1044+
webviewView.onDidDispose(
1045+
async () => {
1046+
// A view that is no longer current must not clear the current
1047+
// view's resources or dispose the provider.
1048+
if (webviewView !== this.view) {
1049+
return
1050+
}
1051+
1052+
if (inTabMode) {
1053+
this.log("Disposing ClineProvider instance for tab view")
1054+
await this.dispose()
1055+
} else {
1056+
this.log("Clearing webview resources for sidebar view")
1057+
this.view = undefined
1058+
this.clearWebviewResources()
1059+
}
1060+
},
1061+
null,
1062+
// View-scoped, like the other view subscriptions: disposed both when
1063+
// the view is disposed and when a different view replaces it.
1064+
this.webviewDisposables,
1065+
)
1066+
10361067
if (inTabMode) {
10371068
setPanel(webviewView, "tab")
10381069
} else if ("onDidChangeVisibility" in webviewView) {
@@ -1052,12 +1083,20 @@ export class ClineProvider
10521083
localResourceRoots: resourceRoots,
10531084
}
10541085

1055-
webviewView.webview.html =
1086+
const html =
10561087
this.contextProxy.extensionMode === vscode.ExtensionMode.Development &&
10571088
process.env.ROO_CODE_THEME_FIXTURE_PROBE !== "1"
10581089
? await this.getHMRHtmlContent(webviewView.webview)
10591090
: await this.getHtmlContent(webviewView.webview)
10601091

1092+
// The HTML load above can finish after the view was disposed or replaced.
1093+
// A stale resolve must not assign HTML to a view the provider no longer owns.
1094+
if (this.isStaleViewResolve(webviewView)) {
1095+
return
1096+
}
1097+
1098+
webviewView.webview.html = html
1099+
10611100
// Initialize out-of-scope variables that need to receive persistent
10621101
// global state values.
10631102
await this.getState().then(
@@ -1088,9 +1127,15 @@ export class ClineProvider
10881127
},
10891128
)
10901129

1130+
// State hydration can also finish after the view was disposed or replaced.
1131+
// A stale resolve must not install listeners on a view the provider no longer owns.
1132+
if (this.isStaleViewResolve(webviewView)) {
1133+
return
1134+
}
1135+
10911136
// Sets up an event listener to listen for messages passed from the webview view context
10921137
// and executes code based on the message that is received.
1093-
this.setWebviewMessageListener(webviewView.webview)
1138+
this.setWebviewMessageListener(webviewView)
10941139
this.webviewDisposables.push(this.webviewFocusTracker.init(this, webviewView))
10951140

10961141
// Listen for when the panel becomes visible.
@@ -1099,6 +1144,11 @@ export class ClineProvider
10991144
// WebviewView and WebviewPanel have all the same properties except
11001145
// for this visibility listener panel.
11011146
const viewStateDisposable = webviewView.onDidChangeViewState(() => {
1147+
// Ignore events from a view that is no longer the current view.
1148+
if (webviewView !== this.view) {
1149+
return
1150+
}
1151+
11021152
if (this.view?.visible) {
11031153
void this.postMessageToWebview({ type: "action", action: "didBecomeVisible" })
11041154
} else {
@@ -1110,6 +1160,11 @@ export class ClineProvider
11101160
} else if ("onDidChangeVisibility" in webviewView) {
11111161
// sidebar
11121162
const visibilityDisposable = webviewView.onDidChangeVisibility(() => {
1163+
// Ignore events from a view that is no longer the current view.
1164+
if (webviewView !== this.view) {
1165+
return
1166+
}
1167+
11131168
if (this.view?.visible) {
11141169
void this.postMessageToWebview({ type: "action", action: "didBecomeVisible" })
11151170
} else {
@@ -1120,24 +1175,13 @@ export class ClineProvider
11201175
this.webviewDisposables.push(visibilityDisposable)
11211176
}
11221177

1123-
// Listen for when the view is disposed
1124-
// This happens when the user closes the view or when the view is closed programmatically
1125-
webviewView.onDidDispose(
1126-
async () => {
1127-
if (inTabMode) {
1128-
this.log("Disposing ClineProvider instance for tab view")
1129-
await this.dispose()
1130-
} else {
1131-
this.log("Clearing webview resources for sidebar view")
1132-
this.clearWebviewResources()
1133-
}
1134-
},
1135-
null,
1136-
this.disposables,
1137-
)
1138-
11391178
// Listen for when color changes
11401179
const configDisposable = vscode.workspace.onDidChangeConfiguration(async (e) => {
1180+
// Ignore configuration refreshes scoped to a view that is no longer current.
1181+
if (webviewView !== this.view) {
1182+
return
1183+
}
1184+
11411185
if (e && e.affectsConfiguration("workbench.colorTheme")) {
11421186
// Sends latest theme name to webview
11431187
await this.postMessageToWebview({ type: "theme", text: JSON.stringify(await getTheme()) })
@@ -1704,13 +1748,19 @@ export class ClineProvider
17041748
* Sets up an event listener to listen for messages passed from the webview context and
17051749
* executes code based on the message that is received.
17061750
*
1707-
* @param webview A reference to the extension webview
1751+
* @param webviewView The view whose webview messages are listened to
17081752
*/
1709-
private setWebviewMessageListener(webview: vscode.Webview) {
1710-
const onReceiveMessage = async (message: WebviewMessage) =>
1711-
webviewMessageHandler(this, message, this.marketplaceManager)
1753+
private setWebviewMessageListener(webviewView: vscode.WebviewView | vscode.WebviewPanel) {
1754+
const onReceiveMessage = async (message: WebviewMessage) => {
1755+
// Ignore messages from a view that is no longer the current view.
1756+
if (webviewView !== this.view) {
1757+
return
1758+
}
1759+
1760+
await webviewMessageHandler(this, message, this.marketplaceManager)
1761+
}
17121762

1713-
const messageDisposable = webview.onDidReceiveMessage(onReceiveMessage)
1763+
const messageDisposable = webviewView.webview.onDidReceiveMessage(onReceiveMessage)
17141764
this.webviewDisposables.push(messageDisposable)
17151765
}
17161766

‎src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -224,8 +224,7 @@ describe("ClineProvider - API Handler Rebuild Guard", () => {
224224
asWebviewUri: vi.fn(),
225225
},
226226
visible: true,
227-
onDidDispose: vi.fn().mockImplementation((callback) => {
228-
callback()
227+
onDidDispose: vi.fn().mockImplementation(() => {
229228
return { dispose: vi.fn() }
230229
}),
231230
onDidChangeVisibility: vi.fn().mockImplementation(() => {

‎src/core/webview/__tests__/ClineProvider.lockApiConfig.spec.ts‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -316,8 +316,7 @@ describe("ClineProvider - Lock API Config Across Modes", () => {
316316
cspSource: "vscode-webview://test-csp-source",
317317
},
318318
visible: true,
319-
onDidDispose: vi.fn().mockImplementation((callback) => {
320-
callback()
319+
onDidDispose: vi.fn().mockImplementation(() => {
321320
return { dispose: vi.fn() }
322321
}),
323322
onDidChangeVisibility: vi.fn().mockImplementation(() => {

‎src/core/webview/__tests__/ClineProvider.spec.ts‎

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -536,8 +536,7 @@ describe("ClineProvider", () => {
536536
cspSource: "vscode-webview://test-csp-source",
537537
},
538538
visible: true,
539-
onDidDispose: vi.fn().mockImplementation((callback) => {
540-
callback()
539+
onDidDispose: vi.fn().mockImplementation(() => {
541540
return { dispose: vi.fn() }
542541
}),
543542
onDidChangeVisibility: vi.fn().mockImplementation(() => {
@@ -4512,8 +4511,7 @@ describe("ClineProvider - Router Models", () => {
45124511
asWebviewUri: vi.fn(),
45134512
},
45144513
visible: true,
4515-
onDidDispose: vi.fn().mockImplementation((callback) => {
4516-
callback()
4514+
onDidDispose: vi.fn().mockImplementation(() => {
45174515
return { dispose: vi.fn() }
45184516
}),
45194517
onDidChangeVisibility: vi.fn().mockImplementation(() => {
@@ -4911,8 +4909,7 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => {
49114909
asWebviewUri: vi.fn(),
49124910
},
49134911
visible: true,
4914-
onDidDispose: vi.fn().mockImplementation((callback) => {
4915-
callback()
4912+
onDidDispose: vi.fn().mockImplementation(() => {
49164913
return { dispose: vi.fn() }
49174914
}),
49184915
onDidChangeVisibility: vi.fn().mockImplementation(() => {

0 commit comments

Comments
 (0)