@@ -147,11 +147,12 @@ function makeDeferred<T>() {
147147const 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+
189209describe ( "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