fix(tui): recompute virtual tail after width resize
Avoid preserving a frozen virtual transcript range when wrapped rows shrink enough that the old tail window no longer covers the viewport.
This commit is contained in:
@@ -8,6 +8,7 @@ import { useVirtualHistory, virtualHistorySnapshotKey } from '../hooks/useVirtua
|
|||||||
|
|
||||||
interface Item {
|
interface Item {
|
||||||
height: number
|
height: number
|
||||||
|
heightAfterResize?: number
|
||||||
key: string
|
key: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,12 +50,17 @@ const viewportIsMounted = (items: readonly Item[], virtualHistory: ReturnType<ty
|
|||||||
return top >= span.top && bottom <= span.bottom
|
return top >= span.top && bottom <= span.bottom
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const itemHeightForColumns = (item: Item | undefined, columns: number) =>
|
||||||
|
columns >= 80 ? (item?.heightAfterResize ?? item?.height ?? 1) : (item?.height ?? 1)
|
||||||
|
|
||||||
function Harness({
|
function Harness({
|
||||||
|
columns = 80,
|
||||||
expose,
|
expose,
|
||||||
height = 10,
|
height = 10,
|
||||||
items,
|
items,
|
||||||
maxMounted = 16
|
maxMounted = 16
|
||||||
}: {
|
}: {
|
||||||
|
columns?: number
|
||||||
expose: React.MutableRefObject<Exposed | null>
|
expose: React.MutableRefObject<Exposed | null>
|
||||||
height?: number
|
height?: number
|
||||||
items: readonly Item[]
|
items: readonly Item[]
|
||||||
@@ -62,9 +68,9 @@ function Harness({
|
|||||||
}) {
|
}) {
|
||||||
const scrollRef = useRef<ScrollBoxHandle | null>(null)
|
const scrollRef = useRef<ScrollBoxHandle | null>(null)
|
||||||
|
|
||||||
const virtualHistory = useVirtualHistory(scrollRef, items, 80, {
|
const virtualHistory = useVirtualHistory(scrollRef, items, columns, {
|
||||||
coldStartCount: 16,
|
coldStartCount: 16,
|
||||||
estimateHeight: index => items[index]?.height ?? 1,
|
estimateHeight: index => itemHeightForColumns(items[index], columns),
|
||||||
maxMounted,
|
maxMounted,
|
||||||
overscan: 2
|
overscan: 2
|
||||||
})
|
})
|
||||||
@@ -85,7 +91,11 @@ function Harness({
|
|||||||
.map(item =>
|
.map(item =>
|
||||||
React.createElement(
|
React.createElement(
|
||||||
Box,
|
Box,
|
||||||
{ height: item.height, key: item.key, ref: virtualHistory.measureRef(item.key) },
|
{
|
||||||
|
height: itemHeightForColumns(item, columns),
|
||||||
|
key: item.key,
|
||||||
|
ref: virtualHistory.measureRef(item.key)
|
||||||
|
},
|
||||||
React.createElement(Text, null, item.key)
|
React.createElement(Text, null, item.key)
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
@@ -139,6 +149,40 @@ describe('useVirtualHistory offset cache reuse', () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('recomputes tail coverage when wrapped rows shrink after a width resize', async () => {
|
||||||
|
const items = Array.from({ length: 100 }, (_, index) => ({
|
||||||
|
height: 4,
|
||||||
|
heightAfterResize: 1,
|
||||||
|
key: `item-${index}`
|
||||||
|
}))
|
||||||
|
|
||||||
|
const expose = { current: null as Exposed | null }
|
||||||
|
const streams = makeStreams()
|
||||||
|
|
||||||
|
const instance = renderSync(
|
||||||
|
React.createElement(Harness, { columns: 40, expose, height: 10, 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, { columns: 80, expose, height: 10, items, maxMounted: 80 }))
|
||||||
|
await delay(80)
|
||||||
|
|
||||||
|
const resizedItems = items.map(item => ({ height: item.heightAfterResize!, key: item.key }))
|
||||||
|
|
||||||
|
expect(viewportIsMounted(resizedItems, 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' },
|
||||||
|
|||||||
@@ -248,8 +248,26 @@ export function useVirtualHistory(
|
|||||||
// During a freeze, drop the frozen range if items shrank past its start
|
// During a freeze, drop the frozen range if items shrank past its start
|
||||||
// (/clear, compaction) — clamping would collapse to an empty mount and
|
// (/clear, compaction) — clamping would collapse to an empty mount and
|
||||||
// flash blank. Fall through to the normal path in that case.
|
// flash blank. Fall through to the normal path in that case.
|
||||||
const frozenRange =
|
const frozenRangeCandidate =
|
||||||
freezeRenders.current > 0 && prevRange.current && prevRange.current[0] < n ? prevRange.current : null
|
freezeRenders.current > 0 && prevRange.current && prevRange.current[0] < n
|
||||||
|
? ([prevRange.current[0], Math.min(prevRange.current[1], n)] as const)
|
||||||
|
: null
|
||||||
|
|
||||||
|
// Width grows can shrink wrapped rows enough that the old tail window no
|
||||||
|
// longer covers the viewport. In that case freezing preserves stale spacers
|
||||||
|
// and visually cuts off the last message, so recompute immediately.
|
||||||
|
const frozenRange = (() => {
|
||||||
|
if (!frozenRangeCandidate || vp <= 0) {
|
||||||
|
return frozenRangeCandidate
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleTop = sticky && !recentManual ? Math.max(0, total - vp) : target
|
||||||
|
const visibleBottom = visibleTop + vp
|
||||||
|
const rangeTop = offsets[frozenRangeCandidate[0]] ?? 0
|
||||||
|
const rangeBottom = offsets[frozenRangeCandidate[1]] ?? total
|
||||||
|
|
||||||
|
return rangeTop <= visibleTop && rangeBottom >= visibleBottom ? frozenRangeCandidate : null
|
||||||
|
})()
|
||||||
|
|
||||||
let start = 0
|
let start = 0
|
||||||
let end = n
|
let end = n
|
||||||
@@ -464,6 +482,7 @@ export function useVirtualHistory(
|
|||||||
|
|
||||||
if (skipMeasurement.current) {
|
if (skipMeasurement.current) {
|
||||||
skipMeasurement.current = false
|
skipMeasurement.current = false
|
||||||
|
bumpMeasuredHeightVersion(n => n + 1)
|
||||||
} else {
|
} else {
|
||||||
for (let i = effStart; i < effEnd; i++) {
|
for (let i = effStart; i < effEnd; i++) {
|
||||||
const k = items[i]?.key
|
const k = items[i]?.key
|
||||||
|
|||||||
Reference in New Issue
Block a user