mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
fix: cancel pane reparent frames (#3477)
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<number>()
|
||||
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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<string, string>
|
||||
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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user