diff --git a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts index 7ca13e2d734..b277b44d74a 100644 --- a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts +++ b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts @@ -128,6 +128,7 @@ export function createPaneDOM( ligaturesAddon: null, compositionHandler: null, pendingSplitScrollState: null, + pendingSplitScrollBufferDisposable: null, debugLabel: options.debugLabel ?? null } @@ -294,6 +295,12 @@ export function disposePane( pane.terminal.element?.removeEventListener('compositionstart', pane.compositionHandler, true) pane.compositionHandler = null } + try { + pane.pendingSplitScrollBufferDisposable?.dispose() + pane.pendingSplitScrollBufferDisposable = null + } catch { + /* ignore */ + } try { pane.ligaturesAddon?.dispose() } catch { diff --git a/src/renderer/src/lib/pane-manager/pane-manager-types.ts b/src/renderer/src/lib/pane-manager/pane-manager-types.ts index 6082ce13a9a..5cb8699f747 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager-types.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager-types.ts @@ -1,4 +1,4 @@ -import type { IMarker, Terminal } from '@xterm/xterm' +import type { IDisposable, IMarker, Terminal } from '@xterm/xterm' import type { ITerminalOptions } from '@xterm/xterm' import type { FitAddon } from '@xterm/addon-fit' import type { LigaturesAddon } from '@xterm/addon-ligatures' @@ -114,6 +114,9 @@ export type ManagedPaneInternal = { // Why: splitPane reparents DOM; its delayed restore owns scroll until the // browser settles, so intermediate fits must not compete with it. pendingSplitScrollState: ScrollState | null + // Stored so repeated split restores and disposePane() can remove the + // deferred alt-screen buffer listener instead of stacking callbacks. + pendingSplitScrollBufferDisposable?: IDisposable | null debugLabel: string | null } & ManagedPane diff --git a/src/renderer/src/lib/pane-manager/pane-split-scroll.test.ts b/src/renderer/src/lib/pane-manager/pane-split-scroll.test.ts index 95d56eb012e..0b3dd5ba91a 100644 --- a/src/renderer/src/lib/pane-manager/pane-split-scroll.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-split-scroll.test.ts @@ -26,11 +26,11 @@ const TEST_LEAF_ID = '11111111-1111-4111-8111-111111111111' as TerminalLeafId function createPane(bufferType: 'normal' | 'alternate'): { pane: ManagedPaneInternal - bufferChangeDisposable: { dispose: ReturnType } - triggerBufferChange: (bufferType: 'normal' | 'alternate') => void + bufferChangeDisposables: { dispose: ReturnType }[] + triggerBufferChange: (bufferType: 'normal' | 'alternate', index?: number) => void } { - let bufferChangeHandler: ((buffer: { type: 'normal' | 'alternate' }) => void) | null = null - const bufferChangeDisposable = { dispose: vi.fn() } + const bufferChangeHandlers: ((buffer: { type: 'normal' | 'alternate' }) => void)[] = [] + const bufferChangeDisposables: { dispose: ReturnType }[] = [] const pane: ManagedPaneInternal = { id: 1, leafId: TEST_LEAF_ID, @@ -44,8 +44,10 @@ function createPane(bufferType: 'normal' | 'alternate'): { length: 24 }, onBufferChange: vi.fn((handler: (buffer: { type: 'normal' | 'alternate' }) => void) => { - bufferChangeHandler = handler - return bufferChangeDisposable + const disposable = { dispose: vi.fn() } + bufferChangeHandlers.push(handler) + bufferChangeDisposables.push(disposable) + return disposable }) } } as never, @@ -72,12 +74,14 @@ function createPane(bufferType: 'normal' | 'alternate'): { webLinksAddon: {} as never, compositionHandler: null, pendingSplitScrollState: scrollState, + pendingSplitScrollBufferDisposable: null, debugLabel: null } return { pane, - bufferChangeDisposable, - triggerBufferChange: (bufferType) => bufferChangeHandler?.({ type: bufferType }) + bufferChangeDisposables, + triggerBufferChange: (bufferType, index = bufferChangeHandlers.length - 1) => + bufferChangeHandlers[index]?.({ type: bufferType }) } } @@ -121,7 +125,7 @@ describe('scheduleSplitScrollRestore', () => { }) it('defers WebGL reattach and skips scroll restore for alternate-screen panes', () => { - const { pane, bufferChangeDisposable, triggerBufferChange } = createPane('alternate') + const { pane, bufferChangeDisposables, triggerBufferChange } = createPane('alternate') const reattachWebgl = vi.fn() scheduleSplitScrollRestore( @@ -139,6 +143,7 @@ describe('scheduleSplitScrollRestore', () => { vi.advanceTimersByTime(200) expect(pane.pendingSplitScrollState).toBeNull() + expect(pane.pendingSplitScrollBufferDisposable).toBe(bufferChangeDisposables[0]) expect(reattachWebgl).not.toHaveBeenCalled() expect(restoreScrollState).not.toHaveBeenCalled() expect(pane.terminal.refresh).not.toHaveBeenCalled() @@ -146,11 +151,12 @@ describe('scheduleSplitScrollRestore', () => { triggerBufferChange('alternate') expect(reattachWebgl).not.toHaveBeenCalled() - expect(bufferChangeDisposable.dispose).not.toHaveBeenCalled() + expect(bufferChangeDisposables[0].dispose).not.toHaveBeenCalled() triggerBufferChange('normal') - expect(bufferChangeDisposable.dispose).toHaveBeenCalledTimes(1) + expect(bufferChangeDisposables[0].dispose).toHaveBeenCalledTimes(1) + expect(pane.pendingSplitScrollBufferDisposable).toBeNull() expect(reattachWebgl).toHaveBeenCalledWith(pane) expect(restoreScrollState).not.toHaveBeenCalled() expect(pane.terminal.refresh).not.toHaveBeenCalled() @@ -181,4 +187,40 @@ describe('scheduleSplitScrollRestore', () => { expect(restoreScrollState).toHaveBeenCalledWith(pane.terminal, scrollState) expect(pane.terminal.refresh).toHaveBeenCalledWith(0, 23) }) + + it('replaces stale deferred buffer listeners when split restore is rescheduled', () => { + const { pane, bufferChangeDisposables, triggerBufferChange } = createPane('alternate') + const reattachWebgl = vi.fn() + + scheduleSplitScrollRestore( + () => pane, + pane.id, + scrollState, + () => false, + reattachWebgl + ) + vi.advanceTimersByTime(200) + + expect(bufferChangeDisposables).toHaveLength(1) + expect(pane.pendingSplitScrollBufferDisposable).toBe(bufferChangeDisposables[0]) + + scheduleSplitScrollRestore( + () => pane, + pane.id, + scrollState, + () => false, + reattachWebgl + ) + vi.advanceTimersByTime(200) + + expect(bufferChangeDisposables).toHaveLength(2) + expect(bufferChangeDisposables[0].dispose).toHaveBeenCalledTimes(1) + expect(pane.pendingSplitScrollBufferDisposable).toBe(bufferChangeDisposables[1]) + + triggerBufferChange('normal') + + expect(bufferChangeDisposables[1].dispose).toHaveBeenCalledTimes(1) + expect(pane.pendingSplitScrollBufferDisposable).toBeNull() + expect(reattachWebgl).toHaveBeenCalledTimes(1) + }) }) diff --git a/src/renderer/src/lib/pane-manager/pane-split-scroll.ts b/src/renderer/src/lib/pane-manager/pane-split-scroll.ts index fa2a256692a..2a1131f18f1 100644 --- a/src/renderer/src/lib/pane-manager/pane-split-scroll.ts +++ b/src/renderer/src/lib/pane-manager/pane-split-scroll.ts @@ -10,6 +10,11 @@ function refreshAfterReparent(pane: ManagedPaneInternal): void { } } +function clearPendingSplitScrollBufferDisposable(pane: ManagedPaneInternal): void { + pane.pendingSplitScrollBufferDisposable?.dispose() + pane.pendingSplitScrollBufferDisposable = null +} + function runAfterNormalBuffer( pane: ManagedPaneInternal, getPaneById: (id: number) => ManagedPaneInternal | undefined, @@ -17,11 +22,15 @@ function runAfterNormalBuffer( isDestroyed: () => boolean, callback: (pane: ManagedPaneInternal) => void ): void { + clearPendingSplitScrollBufferDisposable(pane) let disposable: IDisposable | null = null disposable = pane.terminal.buffer.onBufferChange((buffer: IBuffer) => { if (buffer.type === 'alternate') { return } + if (pane.pendingSplitScrollBufferDisposable === disposable) { + pane.pendingSplitScrollBufferDisposable = null + } disposable?.dispose() disposable = null if (isDestroyed()) { @@ -32,6 +41,7 @@ function runAfterNormalBuffer( callback(live) } }) + pane.pendingSplitScrollBufferDisposable = disposable } function restoreCapturedScrollState( @@ -39,6 +49,7 @@ function restoreCapturedScrollState( scrollState: ScrollState, reattachWebgl?: (pane: ManagedPaneInternal) => void ): void { + clearPendingSplitScrollBufferDisposable(pane) pane.pendingSplitScrollState = null if (reattachWebgl) { reattachWebgl(pane) @@ -104,6 +115,7 @@ export function scheduleSplitScrollRestore( // alternate buffer. Alt-screen has no scrollback, so scroll restore has // nothing legitimate to do. if (scrollState.bufferType === 'alternate') { + clearPendingSplitScrollBufferDisposable(live) live.pendingSplitScrollState = null if (live.terminal.buffer.active.type === 'alternate' && reattachWebgl) { runAfterNormalBuffer(live, getPaneById, paneId, isDestroyed, reattachWebgl)