fix: guard hidden hydration against transport replacement (#2686)

This commit is contained in:
Neil 2026-05-23 02:44:36 -07:00 committed by GitHub
parent 7a9b0465dc
commit b2df6eff74
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 84 additions and 5 deletions

View File

@ -388,6 +388,77 @@ describe('useTerminalPaneGlobalEffects', () => {
expect(terminal.write).toHaveBeenCalledWith('new-output', expect.any(Function))
})
it('does not replay stale hydration after the pane transport is replaced', async () => {
const terminal = {
name: 'terminal-a',
options: { scrollback: 1000 },
write: vi.fn((_data: string, callback?: () => void) => callback?.())
}
const pane = { id: 1, terminal }
const oldTransport = { getPtyId: vi.fn(() => 'pty-1') }
const newTransport = { getPtyId: vi.fn(() => 'pty-2') }
const paneTransports = new Map<number, typeof oldTransport | typeof newTransport>([
[1, oldTransport]
])
const manager = {
getPanes: vi.fn(() => [pane]),
resumeRendering: vi.fn(),
suspendRendering: vi.fn(),
fitAllPanes: vi.fn(),
getActivePane: vi.fn(() => null),
setActivePane: vi.fn()
}
let resolveSnapshot!: (snapshot: { data: string; cols: number; rows: number } | null) => void
const snapshotPromise = new Promise<{ data: string; cols: number; rows: number } | null>(
(resolve) => {
resolveSnapshot = resolve
}
)
window.api.pty.serializeHeadlessBuffer = vi.fn((id: string) => {
if (id === 'pty-1') {
return snapshotPromise
}
return Promise.resolve(null)
}) as typeof window.api.pty.serializeHeadlessBuffer
const baseArgs = {
tabId: 'tab-1',
worktreeId: 'wt-1',
isActive: true,
isVisible: true,
paneCount: 1,
managerRef: { current: manager as never },
containerRef: { current: null },
paneTransportsRef: { current: paneTransports as never },
replayingPanesRef: { current: new Map<number, number>() },
isActiveRef: { current: false },
isVisibleRef: { current: false },
toggleExpandPane: vi.fn()
}
queueHiddenTerminalOutput(terminal, 'pty-1', 'old-output')
beginHookRender()
useTerminalPaneGlobalEffects(baseArgs)
paneTransports.set(1, newTransport)
queueHiddenTerminalOutput(terminal, 'pty-2', 'new-output')
resolveSnapshot({ data: 'stale-headless-output', cols: 120, rows: 40 })
await Promise.resolve()
await Promise.resolve()
expect(terminal.write).not.toHaveBeenCalledWith('stale-headless-output', expect.any(Function))
terminal.write.mockClear()
beginHookRender()
useTerminalPaneGlobalEffects(baseArgs)
await Promise.resolve()
await Promise.resolve()
expect(window.api.pty.serializeHeadlessBuffer).toHaveBeenCalledWith('pty-2', {
scrollbackRows: 1000
})
expect(terminal.write).toHaveBeenCalledWith('new-output', expect.any(Function))
})
it('restores from the pre-hide scroll state when hidden layout changes the viewport', () => {
const terminalA = { name: 'terminal-a' }
const manager = {

View File

@ -88,8 +88,16 @@ export function useTerminalPaneGlobalEffects({
if (!hydration) {
return
}
const transport = paneTransportsRef.current.get(pane.id)
if (transport?.getPtyId() !== hydration.ptyId) {
const getCurrentPtyId = (): string | null => {
const currentPane = managerRef.current
?.getPanes()
.find((candidate) => candidate.id === pane.id)
if (currentPane?.terminal !== pane.terminal) {
return null
}
return paneTransportsRef.current.get(pane.id)?.getPtyId() ?? null
}
if (getCurrentPtyId() !== hydration.ptyId) {
clearHiddenTerminalOutputForPty(pane.terminal, hydration.ptyId)
return
}
@ -104,7 +112,7 @@ export function useTerminalPaneGlobalEffects({
cancelHiddenTerminalHydration(pane.terminal, hydration)
return
}
if (transport.getPtyId() !== hydration.ptyId) {
if (getCurrentPtyId() !== hydration.ptyId) {
clearHiddenTerminalOutputForPty(pane.terminal, hydration.ptyId)
return
}
@ -138,7 +146,7 @@ export function useTerminalPaneGlobalEffects({
cancelHiddenTerminalHydration(pane.terminal, hydration)
return
}
if (transport.getPtyId() !== hydration.ptyId) {
if (getCurrentPtyId() !== hydration.ptyId) {
clearHiddenTerminalOutputForPty(pane.terminal, hydration.ptyId)
return
}
@ -156,7 +164,7 @@ export function useTerminalPaneGlobalEffects({
markHiddenTerminalFallbackReplayed(pane.terminal, hydration)
})
},
[isVisibleRef, paneTransportsRef, replayingPanesRef]
[isVisibleRef, managerRef, paneTransportsRef, replayingPanesRef]
)
useEffect(() => {