diff --git a/src/renderer/src/lib/pane-manager/pane-drag-reorder.ts b/src/renderer/src/lib/pane-manager/pane-drag-reorder.ts index 21970b4c252..569285507ec 100644 --- a/src/renderer/src/lib/pane-manager/pane-drag-reorder.ts +++ b/src/renderer/src/lib/pane-manager/pane-drag-reorder.ts @@ -22,6 +22,7 @@ export type DragReorderCallbacks = { applyPaneOpacity: () => void applyDividerStyles: () => void refitPanesUnder: (el: HTMLElement) => void + requestPaneReparentFrame?: (callback: FrameRequestCallback) => void onLayoutChanged?: () => void onDragActiveChange?: (active: boolean) => void } diff --git a/src/renderer/src/lib/pane-manager/pane-manager.ts b/src/renderer/src/lib/pane-manager/pane-manager.ts index b62443a9efb..c64d94acc25 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines -- Why: PaneManager keeps live pane lifecycle, drag, rendering, and identity callbacks under one owner. */ import type { PaneManagerOptions, PaneStyleOptions, @@ -47,6 +48,7 @@ export class PaneManager { private destroyed = false private renderingSuspended: boolean private identities = new PaneIdentityRegistry() + private pendingPaneReparentFrameIds = new Set() // Drag-to-reorder state private dragState = createDragReorderState() @@ -258,6 +260,7 @@ export class PaneManager { destroy(): void { this.destroyed = true cancelActivePaneDrag(this.dragState) + this.cancelPendingPaneReparentFrames() for (const pane of this.panes.values()) { disposePane(pane, this.panes) } @@ -335,8 +338,35 @@ export class PaneManager { applyPaneOpacity(this.panes.values(), this.activePaneId, this.styleOptions), applyDividerStyles: () => applyDividerStyles(this.root, this.styleOptions), refitPanesUnder: (el: HTMLElement) => refitPanesUnder(el, this.panes), + requestPaneReparentFrame: (callback: FrameRequestCallback) => { + this.requestPaneReparentFrame(callback) + }, onLayoutChanged: this.options.onLayoutChanged, onDragActiveChange: this.options.onPaneDragActiveChange } } + + private requestPaneReparentFrame(callback: FrameRequestCallback): void { + let completed = false + let frameId: number | undefined + frameId = requestAnimationFrame((timestamp) => { + completed = true + if (frameId !== undefined) { + this.pendingPaneReparentFrameIds.delete(frameId) + } + if (!this.destroyed) { + callback(timestamp) + } + }) + if (!completed) { + this.pendingPaneReparentFrameIds.add(frameId) + } + } + + private cancelPendingPaneReparentFrames(): void { + for (const frameId of this.pendingPaneReparentFrameIds) { + cancelAnimationFrame(frameId) + } + this.pendingPaneReparentFrameIds.clear() + } } diff --git a/src/renderer/src/lib/pane-manager/pane-tree-ops.ts b/src/renderer/src/lib/pane-manager/pane-tree-ops.ts index a95d29e3315..807a2cddaa6 100644 --- a/src/renderer/src/lib/pane-manager/pane-tree-ops.ts +++ b/src/renderer/src/lib/pane-manager/pane-tree-ops.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines -- Why: split-tree DOM reparent, promote, and equalize rules need one consistent owner. */ import type { DropZone, ManagedPane, @@ -22,6 +23,8 @@ type TreeOpsCallbacks = { safeFit: (pane: ManagedPane) => void refitPanesUnder: (el: HTMLElement) => void onLayoutChanged?: () => void + isDestroyed?: () => boolean + requestPaneReparentFrame?: (callback: FrameRequestCallback) => void } function getProposedDimensions(pane: ManagedPane): { cols: number; rows: number } | null { @@ -216,7 +219,13 @@ export function insertPaneNextTo( split.appendChild(source.container) } - requestAnimationFrame(() => { + const requestReparentFrame = + callbacks.requestPaneReparentFrame ?? + ((callback: FrameRequestCallback) => requestAnimationFrame(callback)) + requestReparentFrame(() => { + if (callbacks.isDestroyed?.()) { + return + } if (sourceHadWebgl && source.gpuRenderingEnabled && !source.webglDisabledAfterContextLoss) { attachWebgl(source) } diff --git a/src/renderer/src/lib/pane-manager/pane-tree-reparent-frame.test.ts b/src/renderer/src/lib/pane-manager/pane-tree-reparent-frame.test.ts new file mode 100644 index 00000000000..3208eb79d40 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-tree-reparent-frame.test.ts @@ -0,0 +1,158 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { ManagedPaneInternal } from './pane-manager-types' + +const webglRendererMock = vi.hoisted(() => ({ + attachWebgl: vi.fn(), + disposeWebgl: vi.fn() +})) + +vi.mock('./pane-webgl-renderer', () => webglRendererMock) + +vi.mock('./pane-divider', () => ({ + createDivider: vi.fn(() => createMockElement('pane-divider')) +})) + +type TestElement = HTMLElement & { + className: string + children: TestElement[] + parentElement: TestElement | null + style: Record + appendChild: (child: TestElement) => TestElement + replaceChild: (nextChild: TestElement, oldChild: TestElement) => TestElement + remove: () => void +} + +function createMockElement(className = ''): TestElement { + const element = { + className, + children: [], + parentElement: null, + style: {}, + classList: { + contains: (classToken: string): boolean => element.className.split(/\s+/).includes(classToken) + }, + appendChild: (child: TestElement): TestElement => { + element.children.push(child) + child.parentElement = element + return child + }, + replaceChild: (nextChild: TestElement, oldChild: TestElement): TestElement => { + const index = element.children.indexOf(oldChild) + if (index >= 0) { + element.children[index] = nextChild + } else { + element.children.push(nextChild) + } + nextChild.parentElement = element + oldChild.parentElement = null + return oldChild + }, + remove: vi.fn() + } as unknown as TestElement + return element +} + +function createPane(id: number, container = createMockElement('pane')): ManagedPaneInternal { + const leafId = `${id}1111111-1111-4111-8111-111111111111` as never + return { + id, + leafId, + stablePaneId: leafId, + container, + xtermContainer: createMockElement(), + linkTooltip: createMockElement(), + terminal: {} as never, + fitAddon: {} as never, + searchAddon: {} as never, + serializeAddon: {} as never, + unicode11Addon: {} as never, + webLinksAddon: {} as never, + terminalGpuAcceleration: 'on', + gpuRenderingEnabled: true, + webglAttachmentDeferred: false, + webglDisabledAfterContextLoss: false, + hasComplexScriptOutput: false, + webglAddon: {} as never, + ligaturesAddon: null, + fitResizeObserver: null, + pendingObservedFitRafId: null, + compositionHandler: null, + pendingSplitScrollState: null, + debugLabel: null + } +} + +function setupDocument(): void { + vi.stubGlobal('document', { + createElement: vi.fn(() => createMockElement()) + }) +} + +describe('insertPaneNextTo reparent frame', () => { + afterEach(() => { + vi.unstubAllGlobals() + vi.clearAllMocks() + }) + + it('uses the caller-owned frame scheduler for WebGL reattach and refit', async () => { + setupDocument() + const { insertPaneNextTo } = await import('./pane-tree-ops') + const parent = createMockElement('pane-split') + const source = createPane(1) + const target = createPane(2) + parent.appendChild(target.container as TestElement) + const frames: FrameRequestCallback[] = [] + const safeFit = vi.fn() + + insertPaneNextTo(source, target, 'right', { + getRoot: () => parent, + getStyleOptions: () => ({}), + safeFit, + refitPanesUnder: vi.fn(), + requestPaneReparentFrame: (callback) => { + frames.push(callback) + } + }) + + expect(frames).toHaveLength(1) + expect(safeFit).not.toHaveBeenCalled() + + frames[0]?.(16) + + expect(webglRendererMock.disposeWebgl).toHaveBeenCalledWith(source) + expect(webglRendererMock.disposeWebgl).toHaveBeenCalledWith(target) + expect(webglRendererMock.attachWebgl).toHaveBeenCalledWith(source) + expect(webglRendererMock.attachWebgl).toHaveBeenCalledWith(target) + expect(safeFit).toHaveBeenCalledWith(source) + expect(safeFit).toHaveBeenCalledWith(target) + }) + + it('skips the deferred WebGL reattach and refit after manager destruction', async () => { + setupDocument() + const { insertPaneNextTo } = await import('./pane-tree-ops') + const parent = createMockElement('pane-split') + const source = createPane(1) + const target = createPane(2) + parent.appendChild(target.container as TestElement) + const frames: FrameRequestCallback[] = [] + const safeFit = vi.fn() + let destroyed = false + + insertPaneNextTo(source, target, 'right', { + getRoot: () => parent, + getStyleOptions: () => ({}), + safeFit, + refitPanesUnder: vi.fn(), + isDestroyed: () => destroyed, + requestPaneReparentFrame: (callback) => { + frames.push(callback) + } + }) + + destroyed = true + frames[0]?.(16) + + expect(webglRendererMock.attachWebgl).not.toHaveBeenCalled() + expect(safeFit).not.toHaveBeenCalled() + }) +})