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:
@@ -862,7 +862,12 @@ function renderNodeToOutput(
|
|||||||
scrollDrainNode = node
|
scrollDrainNode = node
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((node.scrollTop ?? 0) !== scrollTopBeforeFollow || node.stickyScroll !== stickyBeforeFollow) {
|
if (
|
||||||
|
(node.scrollTop ?? 0) !== scrollTopBeforeFollow ||
|
||||||
|
node.stickyScroll !== stickyBeforeFollow ||
|
||||||
|
scrollHeight !== prevScrollHeight ||
|
||||||
|
innerHeight !== prevInnerHeight
|
||||||
|
) {
|
||||||
node.notifyScrollChange?.()
|
node.notifyScrollChange?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Box, renderSync, ScrollBox, type ScrollBoxHandle, Text } from '@hermes/
|
|||||||
import React, { useLayoutEffect, useRef } from 'react'
|
import React, { useLayoutEffect, useRef } from 'react'
|
||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
import { useVirtualHistory } from '../hooks/useVirtualHistory.js'
|
import { useVirtualHistory, virtualHistorySnapshotKey } from '../hooks/useVirtualHistory.js'
|
||||||
|
|
||||||
interface Item {
|
interface Item {
|
||||||
height: number
|
height: number
|
||||||
@@ -49,13 +49,23 @@ const viewportIsMounted = (items: readonly Item[], virtualHistory: ReturnType<ty
|
|||||||
return top >= span.top && bottom <= span.bottom
|
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 scrollRef = useRef<ScrollBoxHandle | null>(null)
|
||||||
|
|
||||||
const virtualHistory = useVirtualHistory(scrollRef, items, 80, {
|
const virtualHistory = useVirtualHistory(scrollRef, items, 80, {
|
||||||
coldStartCount: 16,
|
coldStartCount: 16,
|
||||||
estimateHeight: index => items[index]?.height ?? 1,
|
estimateHeight: index => items[index]?.height ?? 1,
|
||||||
maxMounted: 16,
|
maxMounted,
|
||||||
overscan: 2
|
overscan: 2
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -65,7 +75,7 @@ function Harness({ expose, items }: { expose: React.MutableRefObject<Exposed | n
|
|||||||
|
|
||||||
return React.createElement(
|
return React.createElement(
|
||||||
ScrollBox,
|
ScrollBox,
|
||||||
{ flexDirection: 'column', height: 10, ref: scrollRef, stickyScroll: true },
|
{ flexDirection: 'column', height, ref: scrollRef, stickyScroll: true },
|
||||||
React.createElement(
|
React.createElement(
|
||||||
Box,
|
Box,
|
||||||
{ flexDirection: 'column', width: '100%' },
|
{ flexDirection: 'column', width: '100%' },
|
||||||
@@ -85,6 +95,50 @@ function Harness({ expose, items }: { expose: React.MutableRefObject<Exposed | n
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('useVirtualHistory offset cache reuse', () => {
|
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 () => {
|
it('recomputes offsets after a mounted row height changes', async () => {
|
||||||
const tall = [
|
const tall = [
|
||||||
{ height: 6, key: 'a' },
|
{ height: 6, key: 'a' },
|
||||||
|
|||||||
@@ -51,6 +51,18 @@ const SLIDE_STEP = 12
|
|||||||
|
|
||||||
const NOOP = () => {}
|
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) => {
|
const upperBound = (arr: ArrayLike<number>, target: number, length = arr.length) => {
|
||||||
let lo = 0
|
let lo = 0
|
||||||
let hi = length
|
let hi = length
|
||||||
@@ -186,19 +198,8 @@ export function useVirtualHistory(
|
|||||||
|
|
||||||
useSyncExternalStore(
|
useSyncExternalStore(
|
||||||
subscribe,
|
subscribe,
|
||||||
() => {
|
() => virtualHistorySnapshotKey(scrollRef.current),
|
||||||
const s = scrollRef.current
|
() => 'none'
|
||||||
|
|
||||||
if (!s) {
|
|
||||||
return NaN
|
|
||||||
}
|
|
||||||
|
|
||||||
const target = s.getScrollTop() + s.getPendingDelta()
|
|
||||||
const bin = Math.floor(target / QUANTUM)
|
|
||||||
|
|
||||||
return s.isSticky() ? ~bin : bin
|
|
||||||
},
|
|
||||||
() => NaN
|
|
||||||
)
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user