From 4fea02cc16981dedb7878ce42e94c821d56d332f Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 23 May 2026 13:41:46 -0500 Subject: [PATCH] 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. --- .../src/ink/render-node-to-output.ts | 7 ++- .../virtualHistoryOffsetCache.test.ts | 62 +++++++++++++++++-- ui-tui/src/hooks/useVirtualHistory.ts | 27 ++++---- 3 files changed, 78 insertions(+), 18 deletions(-) diff --git a/ui-tui/packages/hermes-ink/src/ink/render-node-to-output.ts b/ui-tui/packages/hermes-ink/src/ink/render-node-to-output.ts index a31753c722a..e85aad0f99f 100644 --- a/ui-tui/packages/hermes-ink/src/ink/render-node-to-output.ts +++ b/ui-tui/packages/hermes-ink/src/ink/render-node-to-output.ts @@ -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?.() } diff --git a/ui-tui/src/__tests__/virtualHistoryOffsetCache.test.ts b/ui-tui/src/__tests__/virtualHistoryOffsetCache.test.ts index 5a3e8cd0976..2beaf5b6838 100644 --- a/ui-tui/src/__tests__/virtualHistoryOffsetCache.test.ts +++ b/ui-tui/src/__tests__/virtualHistoryOffsetCache.test.ts @@ -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= span.top && bottom <= span.bottom } -function Harness({ expose, items }: { expose: React.MutableRefObject; items: readonly Item[] }) { +function Harness({ + expose, + height = 10, + items, + maxMounted = 16 +}: { + expose: React.MutableRefObject + height?: number + items: readonly Item[] + maxMounted?: number +}) { const scrollRef = useRef(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 { + 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' }, diff --git a/ui-tui/src/hooks/useVirtualHistory.ts b/ui-tui/src/hooks/useVirtualHistory.ts index ef96ae1078c..dddf999e365 100644 --- a/ui-tui/src/hooks/useVirtualHistory.ts +++ b/ui-tui/src/hooks/useVirtualHistory.ts @@ -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, 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(() => {