fix: cancel terminal pane refresh frames (#3474)

This commit is contained in:
Neil
2026-05-29 22:28:25 -07:00
committed by GitHub
parent e45440c545
commit 13bde9f161
3 changed files with 135 additions and 1 deletions
@@ -23,6 +23,7 @@ import { EMPTY_LAYOUT, serializeTerminalLayout } from './layout-serialization'
import { makePaneKey } from '../../../../shared/stable-pane-id'
import {
applyExpandedLayoutTo,
cancelPendingPaneSizeRefreshFrames,
createExpandCollapseActions,
restoreExpandedLayoutFrom
} from './expand-collapse'
@@ -126,6 +127,7 @@ export default function TerminalPane({
const expandedStyleSnapshotRef = useRef<Map<HTMLElement, { display: string; flex: string }>>(
new Map()
)
const pendingPaneSizeRefreshFrameIdsRef = useRef<number[]>([])
// Why (separate from expandedStyleSnapshotRef): Activity isolation is a
// transient view override that must not collide with the user-facing
// expanded-pane state or the layout snapshot. Keeping its own snapshot
@@ -629,6 +631,7 @@ export default function TerminalPane({
expandedStyleSnapshotRef,
containerRef,
managerRef,
pendingPaneSizeRefreshFrameIdsRef,
setExpandedPaneId,
setTabPaneExpanded,
tabId,
@@ -902,6 +905,10 @@ export default function TerminalPane({
}
}, [])
useEffect(() => {
return () => cancelPendingPaneSizeRefreshFrames({ pendingPaneSizeRefreshFrameIdsRef })
}, [])
const handleRestartCodexPane = useCallback(
(paneId: number) => {
const manager = managerRef.current
@@ -0,0 +1,96 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cancelPendingPaneSizeRefreshFrames, createExpandCollapseActions } from './expand-collapse'
type ExpandCollapseStateForTest = Parameters<typeof createExpandCollapseActions>[0]
function ref<T>(current: T): React.MutableRefObject<T> {
return { current }
}
function createState(
overrides: Partial<ExpandCollapseStateForTest> = {}
): ExpandCollapseStateForTest {
return {
expandedPaneIdRef: ref(null),
expandedStyleSnapshotRef: ref(new Map()),
containerRef: ref(null),
managerRef: ref(null),
pendingPaneSizeRefreshFrameIdsRef: ref([]),
setExpandedPaneId: vi.fn(),
setTabPaneExpanded: vi.fn(),
tabId: 'tab-1',
persistLayoutSnapshot: vi.fn(),
...overrides
}
}
describe('createExpandCollapseActions', () => {
afterEach(() => {
vi.unstubAllGlobals()
})
it('cancels pending pane-size refresh frames', () => {
const cancelAnimationFrame = vi.fn()
vi.stubGlobal(
'requestAnimationFrame',
vi.fn(() => 42)
)
vi.stubGlobal('cancelAnimationFrame', cancelAnimationFrame)
const state = createState()
createExpandCollapseActions(state).refreshPaneSizes(true)
expect(state.pendingPaneSizeRefreshFrameIdsRef.current).toEqual([42])
cancelPendingPaneSizeRefreshFrames(state)
expect(cancelAnimationFrame).toHaveBeenCalledWith(42)
expect(state.pendingPaneSizeRefreshFrameIdsRef.current).toEqual([])
})
it('forgets completed pane-size refresh frames', () => {
const callbacks: FrameRequestCallback[] = []
vi.stubGlobal(
'requestAnimationFrame',
vi.fn((next: FrameRequestCallback) => {
callbacks.push(next)
return 7
})
)
vi.stubGlobal('cancelAnimationFrame', vi.fn())
const state = createState({
managerRef: ref({
getPanes: () => [],
getActivePane: () => null
} as never)
})
createExpandCollapseActions(state).refreshPaneSizes(false)
expect(state.pendingPaneSizeRefreshFrameIdsRef.current).toEqual([7])
const callback = callbacks[0]
if (!callback) {
throw new Error('expected pane-size refresh frame to be scheduled')
}
callback(16)
expect(state.pendingPaneSizeRefreshFrameIdsRef.current).toEqual([])
})
it('does not retain synchronously completed pane-size refresh frames', () => {
vi.stubGlobal(
'requestAnimationFrame',
vi.fn((callback: FrameRequestCallback) => {
callback(16)
return 9
})
)
vi.stubGlobal('cancelAnimationFrame', vi.fn())
const state = createState()
createExpandCollapseActions(state).refreshPaneSizes(false)
expect(state.pendingPaneSizeRefreshFrameIdsRef.current).toEqual([])
})
})
@@ -10,6 +10,7 @@ type ExpandCollapseState = {
managerRef: React.RefObject<PaneManager | null>
setExpandedPaneId: (paneId: number | null) => void
setTabPaneExpanded: (tabId: string, expanded: boolean) => void
pendingPaneSizeRefreshFrameIdsRef: React.MutableRefObject<number[]>
tabId: string
persistLayoutSnapshot: () => void
}
@@ -81,6 +82,36 @@ export function applyExpandedLayoutTo(
return true
}
export function cancelPendingPaneSizeRefreshFrames(
state: Pick<ExpandCollapseState, 'pendingPaneSizeRefreshFrameIdsRef'>
): void {
for (const frameId of state.pendingPaneSizeRefreshFrameIdsRef.current) {
cancelAnimationFrame(frameId)
}
state.pendingPaneSizeRefreshFrameIdsRef.current = []
}
function requestPaneSizeRefreshFrame(
state: ExpandCollapseState,
callback: FrameRequestCallback
): void {
let completed = false
let frameId: number | undefined
frameId = requestAnimationFrame((timestamp) => {
completed = true
if (frameId !== undefined) {
state.pendingPaneSizeRefreshFrameIdsRef.current =
state.pendingPaneSizeRefreshFrameIdsRef.current.filter(
(pendingFrameId) => pendingFrameId !== frameId
)
}
callback(timestamp)
})
if (!completed) {
state.pendingPaneSizeRefreshFrameIdsRef.current.push(frameId)
}
}
export function createExpandCollapseActions(state: ExpandCollapseState) {
const setExpandedPane = (paneId: number | null): void => {
state.expandedPaneIdRef.current = paneId
@@ -99,7 +130,7 @@ export function createExpandCollapseActions(state: ExpandCollapseState) {
// safeFit owns scroll preservation; content matching here jumped to the
// wrong duplicate scrollback line in long sessions.
const refreshPaneSizes = (focusActive: boolean): void => {
requestAnimationFrame(() => {
requestPaneSizeRefreshFrame(state, () => {
const manager = state.managerRef.current
if (!manager) {
return