fix(tui): refresh virtual transcript on viewport resize

Notify scroll subscribers when ScrollBox viewport bounds change and key virtual-history updates on viewport height so resize/keyboard changes remount the tail rows instead of leaving stale spacers visible.
This commit is contained in:
Brooklyn Nicholson 2026-05-23 13:41:46 -05:00
parent 874c2b1fe6
commit 4fea02cc16
3 changed files with 78 additions and 18 deletions

View file

@ -862,7 +862,12 @@ function renderNodeToOutput(
scrollDrainNode = node
}
if ((node.scrollTop ?? 0) !== scrollTopBeforeFollow || node.stickyScroll !== stickyBeforeFollow) {
if (
(node.scrollTop ?? 0) !== scrollTopBeforeFollow ||
node.stickyScroll !== stickyBeforeFollow ||
scrollHeight !== prevScrollHeight ||
innerHeight !== prevInnerHeight
) {
node.notifyScrollChange?.()
}

View file

@ -4,7 +4,7 @@ import { Box, renderSync, ScrollBox, type ScrollBoxHandle, Text } from '@hermes/
import React, { useLayoutEffect, useRef } from 'react'
import { describe, expect, it } from 'vitest'
import { useVirtualHistory } from '../hooks/useVirtualHistory.js'
import { useVirtualHistory, virtualHistorySnapshotKey } from '../hooks/useVirtualHistory.js'
interface Item {
height: number
@ -49,13 +49,23 @@ const viewportIsMounted = (items: readonly Item[], virtualHistory: ReturnType<ty
return top >= span.top && bottom <= span.bottom
}
function Harness({ expose, items }: { expose: React.MutableRefObject<Exposed | null>; items: readonly Item[] }) {
function Harness({
expose,
height = 10,
items,
maxMounted = 16
}: {
expose: React.MutableRefObject<Exposed | null>
height?: number
items: readonly Item[]
maxMounted?: number
}) {
const scrollRef = useRef<ScrollBoxHandle | null>(null)
const virtualHistory = useVirtualHistory(scrollRef, items, 80, {
coldStartCount: 16,
estimateHeight: index => items[index]?.height ?? 1,
maxMounted: 16,
maxMounted,
overscan: 2
})
@ -65,7 +75,7 @@ function Harness({ expose, items }: { expose: React.MutableRefObject<Exposed | n
return React.createElement(
ScrollBox,
{ flexDirection: 'column', height: 10, ref: scrollRef, stickyScroll: true },
{ flexDirection: 'column', height, ref: scrollRef, stickyScroll: true },
React.createElement(
Box,
{ flexDirection: 'column', width: '100%' },
@ -85,6 +95,50 @@ function Harness({ expose, items }: { expose: React.MutableRefObject<Exposed | n
}
describe('useVirtualHistory offset cache reuse', () => {
it('includes viewport height in the external-store snapshot key', () => {
const base = {
getPendingDelta: () => 0,
getScrollTop: () => 20,
isSticky: () => false
}
const short = virtualHistorySnapshotKey({
...base,
getViewportHeight: () => 5
} as ScrollBoxHandle)
const tall = virtualHistorySnapshotKey({
...base,
getViewportHeight: () => 25
} as ScrollBoxHandle)
expect(short).not.toBe(tall)
})
it('remounts enough tail rows after the scroll viewport grows', async () => {
const items = Array.from({ length: 100 }, (_, index) => ({ height: 1, key: `item-${index}` }))
const expose = { current: null as Exposed | null }
const streams = makeStreams()
const instance = renderSync(React.createElement(Harness, { expose, height: 4, items, maxMounted: 80 }), {
patchConsole: false,
stderr: streams.stderr as NodeJS.WriteStream,
stdin: streams.stdin as NodeJS.ReadStream,
stdout: streams.stdout as NodeJS.WriteStream
})
try {
await delay(20)
instance.rerender(React.createElement(Harness, { expose, height: 24, items, maxMounted: 80 }))
await delay(80)
expect(viewportIsMounted(items, expose.current!.virtualHistory, expose.current!.scroll!)).toBe(true)
} finally {
instance.unmount()
instance.cleanup()
}
})
it('recomputes offsets after a mounted row height changes', async () => {
const tall = [
{ height: 6, key: 'a' },

View file

@ -51,6 +51,18 @@ const SLIDE_STEP = 12
const NOOP = () => {}
export const virtualHistorySnapshotKey = (s?: ScrollBoxHandle | null): string => {
if (!s) {
return 'none'
}
const target = s.getScrollTop() + s.getPendingDelta()
const bin = Math.floor(target / QUANTUM)
const viewportHeight = Math.max(0, s.getViewportHeight())
return `${s.isSticky() ? ~bin : bin}:${viewportHeight}`
}
const upperBound = (arr: ArrayLike<number>, target: number, length = arr.length) => {
let lo = 0
let hi = length
@ -186,19 +198,8 @@ export function useVirtualHistory(
useSyncExternalStore(
subscribe,
() => {
const s = scrollRef.current
if (!s) {
return NaN
}
const target = s.getScrollTop() + s.getPendingDelta()
const bin = Math.floor(target / QUANTUM)
return s.isSticky() ? ~bin : bin
},
() => NaN
() => virtualHistorySnapshotKey(scrollRef.current),
() => 'none'
)
useEffect(() => {