perf(tui): bound scroll rendering and preserve anchors

This commit is contained in:
Gabriel Lesperance
2026-07-22 11:31:22 -04:00
committed by kshitij
parent 7598bc6c95
commit 68391930d0
16 changed files with 2641 additions and 320 deletions

View File

@@ -10,9 +10,31 @@ import { markCommitStart } from '../reconciler.js'
import type { Styles } from '../styles.js'
import Box from './Box.js'
const MAX_SCROLL_GEOMETRY = 1_000_000_000
const validUnsignedGeometry = (value: number): boolean =>
Number.isFinite(value) && value >= 0 && value <= MAX_SCROLL_GEOMETRY
const validClampMaximum = (value: number): boolean => value === Number.POSITIVE_INFINITY || validUnsignedGeometry(value)
const validSignedGeometry = (value: number): boolean => Number.isFinite(value) && Math.abs(value) <= MAX_SCROLL_GEOMETRY
const safeUnsignedGeometry = (value: number | undefined): number =>
value !== undefined && validUnsignedGeometry(value) ? value : 0
const safeSignedGeometry = (value: number | undefined): number =>
value !== undefined && validSignedGeometry(value) ? value : 0
export type ScrollBoxHandle = {
scrollTo: (y: number) => void
scrollBy: (dy: number) => void
/**
* Offset the committed viewport after content above it changes height.
* Unlike scrollTo, this preserves pending input, sticky state, anchor seeks,
* and the manual-scroll timestamp.
*/
adjustScrollTop: (dy: number) => void
/**
* Scroll so `el`'s top is at the viewport top (plus `offset`). Unlike
* scrollTo which bakes a number that's stale by the time the throttled
@@ -49,9 +71,9 @@ export type ScrollBoxHandle = {
isSticky: () => boolean
/**
* Subscribe to scroll viewport changes. Fires for imperative scroll changes
* (scrollTo/scrollBy/scrollToBottom) and for renderer-computed scroll bounds
* changes such as content growth or terminal resize. Callers use this to
* keep virtualized ranges aligned with the currently visible viewport.
* (scrollTo/scrollBy/adjustScrollTop/scrollToBottom) and for renderer-computed
* scroll bounds changes such as content growth or terminal resize. Callers
* use this to keep virtualized ranges aligned with the visible viewport.
*/
subscribe: (listener: () => void) => () => void
/**
@@ -85,7 +107,7 @@ export type ScrollBoxProps = Except<Styles, 'textWrap' | 'overflow' | 'overflowX
*/
function ScrollBox({ children, ref, stickyScroll, ...style }: PropsWithChildren<ScrollBoxProps>): React.ReactNode {
const domRef = useRef<DOMElement>(null)
// scrollTo/scrollBy bypass React: they mutate scrollTop on the DOM node,
// Imperative position changes bypass React: they mutate scrollTop on the DOM node,
// mark it dirty, and call the root's throttled scheduleRender directly.
// The Ink renderer reads scrollTop from the node — no React state needed,
// no reconciler overhead per wheel event. The microtask defer coalesces
@@ -127,10 +149,29 @@ function ScrollBox({ children, ref, stickyScroll, ...style }: PropsWithChildren<
useImperativeHandle(
ref,
(): ScrollBoxHandle => ({
adjustScrollTop(dy: number) {
const el = domRef.current
if (!el || !validSignedGeometry(dy)) {
return
}
const current = safeUnsignedGeometry(el.scrollTop)
const next = Math.max(0, current + Math.floor(dy))
const compensation = safeSignedGeometry(el.scrollTopCompensation) + (next - current)
if (next === current || !validUnsignedGeometry(next) || !validSignedGeometry(compensation)) {
return
}
el.scrollTop = next
el.scrollTopCompensation = compensation
scrollMutated(el)
},
scrollTo(y: number) {
const el = domRef.current
if (!el) {
if (!el || !validSignedGeometry(y)) {
return
}
@@ -139,6 +180,7 @@ function ScrollBox({ children, ref, stickyScroll, ...style }: PropsWithChildren<
el.stickyScroll = false
manualScrollAtRef.current = Date.now()
el.pendingScrollDelta = undefined
el.scrollTopCompensation = undefined
el.scrollAnchor = undefined
el.scrollTop = Math.max(0, Math.floor(y))
scrollMutated(el)
@@ -146,13 +188,14 @@ function ScrollBox({ children, ref, stickyScroll, ...style }: PropsWithChildren<
scrollToElement(el: DOMElement, offset = 0) {
const box = domRef.current
if (!box) {
if (!box || !validSignedGeometry(offset)) {
return
}
box.stickyScroll = false
manualScrollAtRef.current = Date.now()
box.pendingScrollDelta = undefined
box.scrollTopCompensation = undefined
box.scrollAnchor = {
el,
offset
@@ -162,14 +205,20 @@ function ScrollBox({ children, ref, stickyScroll, ...style }: PropsWithChildren<
scrollBy(dy: number) {
const el = domRef.current
if (!el) {
if (!el || !validSignedGeometry(dy)) {
return
}
const pending = safeSignedGeometry(el.pendingScrollDelta) + Math.floor(dy)
if (!validSignedGeometry(pending)) {
return
}
el.stickyScroll = false
manualScrollAtRef.current = Date.now()
el.scrollAnchor = undefined
el.pendingScrollDelta = (el.pendingScrollDelta ?? 0) + Math.floor(dy)
el.pendingScrollDelta = pending
scrollMutated(el)
},
scrollToBottom() {
@@ -180,30 +229,35 @@ function ScrollBox({ children, ref, stickyScroll, ...style }: PropsWithChildren<
}
el.pendingScrollDelta = undefined
el.scrollTopCompensation = undefined
el.stickyScroll = true
markDirty(el)
notify()
forceRender(n => n + 1)
},
getScrollTop() {
return domRef.current?.scrollTop ?? 0
return safeUnsignedGeometry(domRef.current?.scrollTop)
},
getPendingDelta() {
// Accumulated-but-not-yet-drained delta. useVirtualScroll needs
// this to mount the union [committed, committed+pending] range —
// otherwise intermediate drain frames find no children (blank).
return domRef.current?.pendingScrollDelta ?? 0
return safeSignedGeometry(domRef.current?.pendingScrollDelta)
},
getScrollHeight() {
return domRef.current?.scrollHeight ?? 0
return safeUnsignedGeometry(domRef.current?.scrollHeight)
},
getFreshScrollHeight() {
const content = domRef.current?.childNodes[0] as DOMElement | undefined
return content?.yogaNode?.getComputedHeight() ?? domRef.current?.scrollHeight ?? 0
const height = content?.yogaNode?.getComputedHeight()
return validUnsignedGeometry(height ?? Number.NaN)
? height!
: safeUnsignedGeometry(domRef.current?.scrollHeight)
},
getViewportHeight() {
return domRef.current?.scrollViewportHeight ?? 0
return safeUnsignedGeometry(domRef.current?.scrollViewportHeight)
},
getViewportTop() {
return domRef.current?.scrollViewportTop ?? 0
@@ -232,6 +286,26 @@ function ScrollBox({ children, ref, stickyScroll, ...style }: PropsWithChildren<
return
}
if (min === undefined && max === undefined) {
el.scrollClampMin = undefined
el.scrollClampMax = undefined
return
}
if (
min === undefined ||
max === undefined ||
!validUnsignedGeometry(min) ||
!validClampMaximum(max) ||
min > max
) {
el.scrollClampMin = undefined
el.scrollClampMax = undefined
return
}
el.scrollClampMin = min
el.scrollClampMax = max
}
@@ -260,7 +334,7 @@ function ScrollBox({ children, ref, stickyScroll, ...style }: PropsWithChildren<
domRef.current = el
if (el) {
el.scrollTop ??= 0
el.scrollTop = safeUnsignedGeometry(el.scrollTop)
el.notifyScrollChange = notify
}
}}

View File

@@ -53,6 +53,11 @@ export type DOMElement = {
// intermediate frames instead of one big jump. Direction reversal
// naturally cancels (pure accumulator, no target tracking).
pendingScrollDelta?: number
// One-render record of additive scrollTop changes made to preserve the
// visual anchor after content above the viewport changes height. The
// renderer subtracts this when evaluating positional bottom-follow and
// defers pending input for that paint, then clears the record.
scrollTopCompensation?: number
// Render-time clamp bounds for virtual scroll. useVirtualScroll writes
// the currently-mounted children's coverage span; render-node-to-output
// clamps scrollTop to stay within it. Prevents blank screen when

View File

@@ -0,0 +1,200 @@
import { describe, expect, it } from 'vitest'
import type { DOMNode } from './dom.js'
import Output from './output.js'
import renderBorder from './render-border.js'
import { cellAt, CellWidth, CharPool, createScreen, HyperlinkPool, type Screen, StylePool } from './screen.js'
const WIDTH = 12
const HEIGHT = 6
function createOutput() {
const stylePool = new StylePool()
const screen = createScreen(WIDTH, HEIGHT, stylePool, new CharPool(), new HyperlinkPool())
return { output: new Output({ height: HEIGHT, screen, stylePool, width: WIDTH }), stylePool }
}
function borderNode(style: Record<string, unknown> = {}, width = 8, height = 4): DOMNode {
return {
style: { borderStyle: 'single', ...style },
yogaNode: {
getComputedHeight: () => height,
getComputedWidth: () => width
}
} as unknown as DOMNode
}
function snapshot(screen: Screen, styles: StylePool) {
return Array.from({ length: screen.height }, (_, y) =>
Array.from({ length: screen.width }, (_, x) => {
const cell = cellAt(screen, x, y)!
return [cell.char, cell.width, styles.get(cell.styleId).map(code => code.code)] as const
})
)
}
function paint(
node: DOMNode,
visible: readonly [number, number, number, number] = [0, WIDTH, 0, HEIGHT],
decorate?: (output: Output, phase: 'before' | 'after') => void
) {
const { output, stylePool } = createOutput()
decorate?.(output, 'before')
renderBorder(1, 1, node, output, ...visible)
decorate?.(output, 'after')
return { cells: snapshot(output.get(), stylePool), stylePool }
}
function expectClippedParity(
full: ReturnType<typeof paint>['cells'],
clipped: ReturnType<typeof paint>['cells'],
[x1, x2, y1, y2]: readonly [number, number, number, number]
) {
for (let y = 0; y < HEIGHT; y++) {
for (let x = 0; x < WIDTH; x++) {
if (x >= x1 && x < x2 && y >= y1 && y < y2) {
expect(clipped[y]![x]).toEqual(full[y]![x])
} else {
expect(clipped[y]![x]![0]).toBe(' ')
}
}
}
}
function expectWideTextClippedParity(
full: ReturnType<typeof paint>['cells'],
clipped: ReturnType<typeof paint>['cells'],
visible: readonly [number, number, number, number]
) {
const [x1, x2, y1, y2] = visible
for (let y = 0; y < HEIGHT; y++) {
for (let x = 0; x < WIDTH; x++) {
const fullCell = full[y]![x]!
const isVisible = x >= x1 && x < x2 && y >= y1 && y < y2
const clipsWideHead = isVisible && fullCell[1] === CellWidth.Wide && x + 1 >= x2
const clipsWideTail = isVisible && fullCell[1] === CellWidth.SpacerTail && x - 1 < x1
if (clipsWideHead || clipsWideTail) {
expect(clipped[y]![x]).toEqual([' ', CellWidth.Narrow, []])
} else if (isVisible) {
expect(clipped[y]![x]).toEqual(fullCell)
} else {
expect(clipped[y]![x]![0]).toBe(' ')
}
}
}
}
describe('renderBorder viewport parity', () => {
it.each([
['all edges', {}],
['disabled edges', { borderLeft: false, borderTop: false }]
])('matches the unclipped border inside a clipped viewport with %s', (_name, style) => {
const visible = [3, 8, 1, 4] as const
const full = paint(borderNode(style)).cells
const clipped = paint(borderNode(style), visible).cells
expectClippedParity(full, clipped, visible)
})
it.each([
['left edge at wide title', [3, 9, 1, 2]],
['left edge bisects wide title', [4, 9, 1, 2]],
['left edge after wide grapheme', [5, 9, 1, 2]],
['right edge bisects wide title', [1, 4, 1, 2]],
['right edge after wide grapheme', [1, 5, 1, 2]],
['right edge after narrow suffix', [1, 6, 1, 2]]
] as const)('preserves ANSI wide-title cell coordinates when the %s', (_name, visible) => {
const node = borderNode({
borderText: {
align: 'center',
content: '\u001B[31m界A\u001B[39m',
position: 'top'
}
})
const full = paint(node).cells
const clipped = paint(node, visible).cells
expectWideTextClippedParity(full, clipped, visible)
expect(full[1]!.some(([char]) => char === '界')).toBe(true)
expect(full[1]!.some(([char, , codes]) => char === '界' && codes.includes('\u001B[31m'))).toBe(true)
expect(full[1]![6]![2]).not.toContain('\u001B[31m')
if (visible[0] === 4) {
expect(clipped[1]![4]).toEqual([' ', CellWidth.Narrow, []])
expect(clipped[1]![5]![0]).toBe('A')
}
})
it.each([
['left edge inside title', [4, 9, 1, 2]],
['right edge inside title', [1, 5, 1, 2]]
] as const)('preserves narrow ANSI-title parity when the %s', (_name, visible) => {
const node = borderNode({
borderText: {
align: 'center',
content: '\u001B[32mABC\u001B[39m',
position: 'top'
}
})
const full = paint(node).cells
const clipped = paint(node, visible).cells
expectClippedParity(full, clipped, visible)
})
it('intersects nested output clips without changing surviving border cells', () => {
const node = borderNode()
const full = paint(node).cells
const { output, stylePool } = createOutput()
output.clip({ x1: 0, x2: 10, y1: 0, y2: 5 })
output.clip({ x1: 1, x2: 7, y1: 1, y2: 4 })
renderBorder(1, 1, node, output)
output.unclip()
output.unclip()
const nested = snapshot(output.get(), stylePool)
expectClippedParity(full, nested, [1, 7, 1, 4])
})
it('renders borders after opaque fills and preserves the fill interior', () => {
const decorated = paint(borderNode(), [0, WIDTH, 0, HEIGHT], (output, phase) => {
if (phase === 'before') {
const line = '\u001B[44m \u001B[49m'
output.write(1, 1, [line, line, line, line].join('\n'))
}
})
expect(decorated.cells[1]![1]![0]).toBe('┌')
expect(decorated.cells[4]![8]![0]).toBe('┘')
expect(decorated.cells[2]![2]![2]).toContain('\u001B[44m')
})
it('keeps clipped border parity when an absolute-style overlay paints afterward', () => {
const overlay = (output: Output, phase: 'before' | 'after') => {
if (phase === 'after') {
output.clip({ x1: 4, x2: 6, y1: 1, y2: 2 })
output.write(4, 1, 'OV')
output.unclip()
}
}
const visible = [3, 7, 1, 4] as const
const full = paint(borderNode(), [0, WIDTH, 0, HEIGHT], overlay).cells
const clipped = paint(borderNode(), visible, overlay).cells
expectClippedParity(full, clipped, visible)
expect(clipped[1]![4]![0]).toBe('O')
expect(clipped[1]![5]![0]).toBe('V')
})
})

View File

@@ -1,6 +1,8 @@
import chalk from 'chalk'
import cliBoxes, { type Boxes, type BoxStyle } from 'cli-boxes'
import sliceAnsi from '../utils/sliceAnsi.js'
import { applyColor } from './colorize.js'
import type { DOMNode } from './dom.js'
import type Output from './output.js'
@@ -30,41 +32,6 @@ export const CUSTOM_BORDER_STYLES = {
export type BorderStyle = keyof Boxes | keyof typeof CUSTOM_BORDER_STYLES | BoxStyle
function embedTextInBorder(
borderLine: string,
text: string,
align: 'start' | 'end' | 'center',
offset: number = 0,
borderChar: string
): [before: string, text: string, after: string] {
const textLength = stringWidth(text)
const borderLength = borderLine.length
if (textLength >= borderLength - 2) {
return ['', text.substring(0, borderLength), '']
}
let position: number
if (align === 'center') {
position = Math.floor((borderLength - textLength) / 2)
} else if (align === 'start') {
position = offset + 1 // +1 to account for corner character
} else {
// align === 'end'
position = borderLength - textLength - offset - 1 // -1 for corner character
}
// Ensure position is valid
position = Math.max(1, Math.min(position, borderLength - textLength - 1))
const before = borderLine.substring(0, 1) + borderChar.repeat(position - 1)
const after = borderChar.repeat(borderLength - position - textLength - 1) + borderLine.substring(borderLength - 1)
return [before, text, after]
}
function styleBorderLine(line: string, color: Color | undefined, dim: boolean | undefined): string {
let styled = applyColor(line, color)
@@ -75,7 +42,138 @@ function styleBorderLine(line: string, color: Color | undefined, dim: boolean |
return styled
}
const renderBorder = (x: number, y: number, node: DOMNode, output: Output): void => {
function sliceAnsiToWidth(text: string, start: number, end: number): { leadingColumns: number; text: string } {
// sliceAnsi omits a wide grapheme bisected by `start`; retain its partial
// cell as blank space so later graphemes keep their source coordinates.
const leadingColumns = Math.max(0, stringWidth(sliceAnsi(text, 0, start)) - start)
let sliced = sliceAnsi(text, start, end)
if (stringWidth(sliced) > end - start - leadingColumns) {
sliced = sliceAnsi(text, start, end - 1)
}
return { leadingColumns, text: sliced }
}
function borderRun(
start: number,
end: number,
borderLength: number,
borderChar: string,
startCorner: string,
endCorner: string
): string {
if (start >= end) {
return ''
}
const includeStartCorner = start === 0 && startCorner.length > 0
const includeEndCorner = end === borderLength && endCorner.length > 0
const repeated = Math.max(0, end - start - (includeStartCorner ? 1 : 0) - (includeEndCorner ? 1 : 0))
return (includeStartCorner ? startCorner : '') + borderChar.repeat(repeated) + (includeEndCorner ? endCorner : '')
}
function renderHorizontalBorder(
x: number,
y: number,
width: number,
output: Output,
visibleX1: number,
visibleX2: number,
borderChar: string,
startCorner: string,
endCorner: string,
color: Color | undefined,
dim: boolean | undefined,
borderText: BorderTextOptions | undefined
): void {
const borderLength =
Math.max(0, width - (startCorner ? 1 : 0) - (endCorner ? 1 : 0)) + (startCorner ? 1 : 0) + (endCorner ? 1 : 0)
const clippedX1 = Math.max(0, Math.floor(visibleX1))
const clippedX2 = Math.min(output.width, Math.ceil(visibleX2))
const sliceStart = Math.max(0, Math.ceil(clippedX1 - x))
const sliceEnd = Math.min(borderLength, Math.ceil(clippedX2 - x))
if (
!Number.isSafeInteger(width) ||
width < 0 ||
!Number.isSafeInteger(sliceStart) ||
!Number.isSafeInteger(sliceEnd) ||
sliceStart >= sliceEnd
) {
return
}
const writeBorderRun = (start: number, end: number) => {
const text = borderRun(start, end, borderLength, borderChar, startCorner, endCorner)
if (text) {
output.write(x + start, y, styleBorderLine(text, color, dim))
}
}
if (!borderText) {
writeBorderRun(sliceStart, sliceEnd)
return
}
const textLength = stringWidth(borderText.content)
if (textLength >= borderLength - 2) {
const { leadingColumns, text } = sliceAnsiToWidth(borderText.content, sliceStart, sliceEnd)
if (text) {
output.write(x + sliceStart + leadingColumns, y, text)
}
return
}
let position: number
if (borderText.align === 'center') {
position = Math.floor((borderLength - textLength) / 2)
} else if (borderText.align === 'start') {
position = (borderText.offset ?? 0) + 1
} else {
position = borderLength - textLength - (borderText.offset ?? 0) - 1
}
position = Math.max(1, Math.min(position, borderLength - textLength - 1))
writeBorderRun(sliceStart, Math.min(sliceEnd, position))
const visibleTextStart = Math.max(sliceStart, position)
const visibleTextEnd = Math.min(sliceEnd, position + textLength)
if (visibleTextStart < visibleTextEnd) {
const { leadingColumns, text } = sliceAnsiToWidth(
borderText.content,
visibleTextStart - position,
visibleTextEnd - position
)
if (text) {
output.write(x + visibleTextStart + leadingColumns, y, text)
}
}
writeBorderRun(Math.max(sliceStart, position + textLength), sliceEnd)
}
const renderBorder = (
x: number,
y: number,
node: DOMNode,
output: Output,
visibleX1 = 0,
visibleX2 = output.width,
visibleY1 = 0,
visibleY2 = output.height
): void => {
if (node.style.borderStyle) {
const width = Math.floor(node.yogaNode!.getComputedWidth())
const height = Math.floor(node.yogaNode!.getComputedHeight())
@@ -107,98 +205,75 @@ const renderBorder = (x: number, y: number, node: DOMNode, output: Output): void
const showLeftBorder = node.style.borderLeft !== false
const showRightBorder = node.style.borderRight !== false
const contentWidth = Math.max(0, width - (showLeftBorder ? 1 : 0) - (showRightBorder ? 1 : 0))
const verticalTop = Math.floor(y + (showTopBorder ? 1 : 0))
const verticalBottom = Math.ceil(y + height - (showBottomBorder ? 1 : 0))
const clippedVerticalTop = Math.max(0, Math.floor(visibleY1), verticalTop)
const clippedVerticalBottom = Math.min(output.height, Math.ceil(visibleY2), verticalBottom)
const clippedVerticalHeight = Math.max(0, clippedVerticalBottom - clippedVerticalTop)
const topBorderLine = showTopBorder
? (showLeftBorder ? box.topLeft : '') + box.top.repeat(contentWidth) + (showRightBorder ? box.topRight : '')
: ''
// Handle text in top border
let topBorder: string | undefined
if (showTopBorder && node.style.borderText?.position === 'top') {
const [before, text, after] = embedTextInBorder(
topBorderLine,
node.style.borderText.content,
node.style.borderText.align,
node.style.borderText.offset,
box.top
)
topBorder =
styleBorderLine(before, topBorderColor, dimTopBorderColor) +
text +
styleBorderLine(after, topBorderColor, dimTopBorderColor)
} else if (showTopBorder) {
topBorder = styleBorderLine(topBorderLine, topBorderColor, dimTopBorderColor)
}
let verticalBorderHeight = height
if (showTopBorder) {
verticalBorderHeight -= 1
}
if (showBottomBorder) {
verticalBorderHeight -= 1
}
verticalBorderHeight = Math.max(0, verticalBorderHeight)
let leftBorder = (applyColor(box.left, leftBorderColor) + '\n').repeat(verticalBorderHeight)
let leftBorder = (applyColor(box.left, leftBorderColor) + '\n').repeat(clippedVerticalHeight)
if (dimLeftBorderColor) {
leftBorder = chalk.dim(leftBorder)
}
let rightBorder = (applyColor(box.right, rightBorderColor) + '\n').repeat(verticalBorderHeight)
let rightBorder = (applyColor(box.right, rightBorderColor) + '\n').repeat(clippedVerticalHeight)
if (dimRightBorderColor) {
rightBorder = chalk.dim(rightBorder)
}
const bottomBorderLine = showBottomBorder
? (showLeftBorder ? box.bottomLeft : '') +
box.bottom.repeat(contentWidth) +
(showRightBorder ? box.bottomRight : '')
: ''
// Handle text in bottom border
let bottomBorder: string | undefined
if (showBottomBorder && node.style.borderText?.position === 'bottom') {
const [before, text, after] = embedTextInBorder(
bottomBorderLine,
node.style.borderText.content,
node.style.borderText.align,
node.style.borderText.offset,
box.bottom
if (showTopBorder && y >= visibleY1 && y < visibleY2 && y >= 0 && y < output.height) {
renderHorizontalBorder(
x,
y,
width,
output,
visibleX1,
visibleX2,
box.top,
showLeftBorder ? box.topLeft : '',
showRightBorder ? box.topRight : '',
topBorderColor,
dimTopBorderColor,
node.style.borderText?.position === 'top' ? node.style.borderText : undefined
)
bottomBorder =
styleBorderLine(before, bottomBorderColor, dimBottomBorderColor) +
text +
styleBorderLine(after, bottomBorderColor, dimBottomBorderColor)
} else if (showBottomBorder) {
bottomBorder = styleBorderLine(bottomBorderLine, bottomBorderColor, dimBottomBorderColor)
}
const offsetY = showTopBorder ? 1 : 0
if (topBorder) {
output.write(x, y, topBorder)
if (showLeftBorder && clippedVerticalHeight > 0 && x >= visibleX1 && x < visibleX2 && x >= 0 && x < output.width) {
output.write(x, clippedVerticalTop, leftBorder)
}
if (showLeftBorder) {
output.write(x, y + offsetY, leftBorder)
const rightX = x + width - 1
if (
showRightBorder &&
clippedVerticalHeight > 0 &&
rightX >= visibleX1 &&
rightX < visibleX2 &&
rightX >= 0 &&
rightX < output.width
) {
output.write(rightX, clippedVerticalTop, rightBorder)
}
if (showRightBorder) {
output.write(x + width - 1, y + offsetY, rightBorder)
}
const bottomY = y + height - 1
if (bottomBorder) {
output.write(x, y + height - 1, bottomBorder)
if (showBottomBorder && bottomY >= visibleY1 && bottomY < visibleY2 && bottomY >= 0 && bottomY < output.height) {
renderHorizontalBorder(
x,
bottomY,
width,
output,
visibleX1,
visibleX2,
box.bottom,
showLeftBorder ? box.bottomLeft : '',
showRightBorder ? box.bottomRight : '',
bottomBorderColor,
dimBottomBorderColor,
node.style.borderText?.position === 'bottom' ? node.style.borderText : undefined
)
}
}
}

View File

@@ -15,6 +15,33 @@ import { isXtermJs } from './terminal.js'
import { widestLine } from './widest-line.js'
import wrapText from './wrap-text.js'
const MAX_SCROLL_GEOMETRY = 1_000_000_000
const MAX_YOGA_DIMENSION = 100_000_000
const validUnsignedGeometry = (value: number): boolean =>
Number.isFinite(value) && value >= 0 && value <= MAX_SCROLL_GEOMETRY
const validClampMaximum = (value: number): boolean => value === Number.POSITIVE_INFINITY || validUnsignedGeometry(value)
const validSignedGeometry = (value: number): boolean => Number.isFinite(value) && Math.abs(value) <= MAX_SCROLL_GEOMETRY
const safeUnsignedGeometry = (value: number | undefined, fallback = 0): number =>
value !== undefined && validUnsignedGeometry(value) ? value : fallback
const safeSignedGeometry = (value: number | undefined, fallback = 0): number =>
value !== undefined && validSignedGeometry(value) ? value : fallback
const validYogaDimension = (value: number): boolean =>
Number.isFinite(value) && value >= 0 && value <= MAX_YOGA_DIMENSION
const validYogaRect = (x: number, y: number, width: number, height: number): boolean =>
validSignedGeometry(x) &&
validSignedGeometry(y) &&
validYogaDimension(width) &&
validYogaDimension(height) &&
validSignedGeometry(x + width) &&
validSignedGeometry(y + height)
// Matches detectXtermJsWheel() in ScrollKeybindingHandler.tsx — the curve
// and drain must agree on terminal detection. TERM_PROGRAM check is the sync
// fallback; isXtermJs() is the authoritative XTVERSION-probe result.
@@ -370,12 +397,30 @@ function wrapWithSoftWrap(
// and use it as offset for the rest of the nodes
// Only first node is taken into account, because other text nodes can't have margin or padding,
// so their coordinates will be relative to the first node anyway
function applyPaddingToText(node: DOMElement, text: string, softWrap?: boolean[]): string {
function applyPaddingToText(
node: DOMElement,
text: string,
softWrap: boolean[] | undefined,
maxOffsetX: number,
maxOffsetY: number
): string {
const yogaNode = node.childNodes[0]?.yogaNode
if (yogaNode) {
const offsetX = yogaNode.getComputedLeft()
const offsetY = yogaNode.getComputedTop()
if (
!Number.isSafeInteger(offsetX) ||
offsetX < 0 ||
offsetX > maxOffsetX ||
!Number.isSafeInteger(offsetY) ||
offsetY < 0 ||
offsetY > maxOffsetY
) {
return ''
}
text = '\n'.repeat(offsetY) + indentString(text, offsetX)
if (softWrap && offsetY > 0) {
@@ -397,7 +442,11 @@ function renderNodeToOutput(
offsetY = 0,
prevScreen,
skipSelfBlit = false,
inheritedBackgroundColor
inheritedBackgroundColor,
visibleX1 = 0,
visibleX2 = output.width,
visibleY1 = 0,
visibleY2 = output.height
}: {
offsetX?: number
offsetY?: number
@@ -409,6 +458,10 @@ function renderNodeToOutput(
// opaque descendants' narrower rects are safe to blit.
skipSelfBlit?: boolean
inheritedBackgroundColor?: Color
visibleX1?: number
visibleX2?: number
visibleY1?: number
visibleY2?: number
}
): void {
const { yogaNode } = node
@@ -456,6 +509,21 @@ function renderNodeToOutput(
y = 0
}
// Yoga values are an untrusted renderer boundary. Invalid or implausible
// dimensions must never reach culling, string construction, or recursive
// rendering: NaN makes every comparison false, while huge finite heights
// can turn an opaque box or border into a catastrophic allocation.
if (!validYogaRect(x, y, width, height)) {
dropSubtreeCache(node)
return
}
const activeVisibleX1 = Math.max(0, Math.floor(visibleX1))
const activeVisibleX2 = Math.min(output.width, Math.ceil(visibleX2))
const activeVisibleY1 = Math.max(0, Math.floor(visibleY1))
const activeVisibleY2 = Math.min(output.height, Math.ceil(visibleY2))
// Check if we can skip this subtree (clean node with unchanged layout).
// Blit cells from previous screen instead of re-rendering.
const cached = nodeCache.get(node)
@@ -504,15 +572,26 @@ function renderNodeToOutput(
}
if (cached && (node.dirty || positionChanged)) {
output.clear(
{
x: Math.floor(cached.x),
y: Math.floor(cached.y),
width: Math.floor(cached.width),
height: Math.floor(cached.height)
},
node.style.position === 'absolute'
)
if (validYogaRect(cached.x, cached.y, cached.width, cached.height)) {
const clearX1 = Math.max(activeVisibleX1, Math.floor(cached.x))
const clearX2 = Math.min(activeVisibleX2, Math.ceil(cached.x + cached.width))
const clearY1 = Math.max(activeVisibleY1, Math.floor(cached.y))
const clearY2 = Math.min(activeVisibleY2, Math.ceil(cached.y + cached.height))
if (clearX1 < clearX2 && clearY1 < clearY2) {
output.clear(
{
x: clearX1,
y: clearY1,
width: clearX2 - clearX1,
height: clearY2 - clearY1
},
node.style.position === 'absolute'
)
}
} else {
dropSubtreeCache(node)
}
}
// Read before deleting — hasRemovedChild disables prevScreen blitting
@@ -633,7 +712,7 @@ function renderNodeToOutput(
.join('')
}
text = applyPaddingToText(node, text, softWrap)
text = applyPaddingToText(node, text, softWrap, output.width, output.height)
output.write(x, y, text, softWrap)
}
@@ -670,13 +749,15 @@ function renderNodeToOutput(
const isScrollY = overflowY === 'scroll'
const needsClip = clipHorizontally || clipVertically
let x1: number | undefined
let x2: number | undefined
let y1: number | undefined
let y2: number | undefined
if (needsClip) {
const x1 = clipHorizontally ? x + yogaNode.getComputedBorder(LayoutEdge.Left) : undefined
x1 = clipHorizontally ? x + yogaNode.getComputedBorder(LayoutEdge.Left) : undefined
const x2 = clipHorizontally
x2 = clipHorizontally
? x + yogaNode.getComputedWidth() - yogaNode.getComputedBorder(LayoutEdge.Right)
: undefined
@@ -689,6 +770,11 @@ function renderNodeToOutput(
output.clip({ x1, x2, y1, y2 })
}
const childVisibleX1 = Math.max(activeVisibleX1, Math.floor(x1 ?? activeVisibleX1))
const childVisibleX2 = Math.min(activeVisibleX2, Math.ceil(x2 ?? activeVisibleX2))
const childVisibleY1 = Math.max(activeVisibleY1, Math.floor(y1 ?? activeVisibleY1))
const childVisibleY2 = Math.min(activeVisibleY2, Math.ceil(y2 ?? activeVisibleY2))
if (isScrollY) {
// Scroll containers follow the ScrollBox component structure:
// a single content-wrapper child with flexShrink:0 (doesn't shrink
@@ -698,9 +784,8 @@ function renderNodeToOutput(
// culled against the visible window.
const padTop = yogaNode.getComputedPadding(LayoutEdge.Top)
const innerHeight = Math.max(
0,
(y2 ?? y + height) - (y1 ?? y) - padTop - yogaNode.getComputedPadding(LayoutEdge.Bottom)
const innerHeight = safeUnsignedGeometry(
Math.max(0, (y2 ?? y + height) - (y1 ?? y) - padTop - yogaNode.getComputedPadding(LayoutEdge.Bottom))
)
const content = node.childNodes.find(c => (c as DOMElement).yogaNode) as DOMElement | undefined
@@ -710,31 +795,32 @@ function renderNodeToOutput(
// after terminal resizes Yoga can leave tall descendants overflowing
// that wrapper. Use the deepest direct child bottom so sticky-bottom
// math can still reach the real final rendered row.
let scrollHeight = Math.ceil(contentYoga?.getComputedHeight() ?? 0)
let scrollHeight = safeUnsignedGeometry(Math.ceil(contentYoga?.getComputedHeight() ?? 0))
if (content) {
for (const child of content.childNodes) {
const childYoga = (child as DOMElement).yogaNode
if (childYoga) {
scrollHeight = Math.max(
scrollHeight,
Math.ceil(childYoga.getComputedTop() + childYoga.getComputedHeight())
)
const childBottom = Math.ceil(childYoga.getComputedTop() + childYoga.getComputedHeight())
if (validUnsignedGeometry(childBottom)) {
scrollHeight = Math.max(scrollHeight, childBottom)
}
}
}
}
// Capture previous scroll bounds BEFORE overwriting — the at-bottom
// follow check compares against last frame's max.
const prevScrollHeight = node.scrollHeight ?? scrollHeight
const prevInnerHeight = node.scrollViewportHeight ?? innerHeight
const prevScrollHeight = safeUnsignedGeometry(node.scrollHeight, scrollHeight)
const prevInnerHeight = safeUnsignedGeometry(node.scrollViewportHeight, innerHeight)
node.scrollHeight = scrollHeight
node.scrollViewportHeight = innerHeight
// Absolute screen-buffer row where the scrollable area (inside
// padding) begins. Exposed via ScrollBoxHandle.getViewportTop() so
// drag-to-scroll can detect when the drag leaves the scroll viewport.
node.scrollViewportTop = (y1 ?? y) + padTop
node.scrollViewportTop = safeUnsignedGeometry((y1 ?? y) + padTop)
const maxScroll = Math.max(0, scrollHeight - innerHeight)
@@ -751,9 +837,16 @@ function renderNodeToOutput(
// plumbing; shipping instant first. stickyScroll overrides.
if (node.scrollAnchor) {
const anchorTop = node.scrollAnchor.el.yogaNode?.getComputedTop()
const anchorOffset = node.scrollAnchor.offset
const anchorTarget = (anchorTop ?? Number.NaN) + anchorOffset
if (anchorTop != null) {
node.scrollTop = anchorTop + node.scrollAnchor.offset
if (
anchorTop != null &&
validUnsignedGeometry(anchorTop) &&
validSignedGeometry(anchorOffset) &&
validUnsignedGeometry(anchorTarget)
) {
node.scrollTop = anchorTarget
node.pendingScrollDelta = undefined
}
@@ -771,8 +864,17 @@ function renderNodeToOutput(
// Capture scrollTop before follow so ink.tsx can translate any
// active text selection by the same delta (native terminal behavior:
// view keeps scrolling, highlight walks up with the text).
const scrollTopBeforeFollow = node.scrollTop ?? 0
const scrollTopBeforeFollow = safeUnsignedGeometry(node.scrollTop)
const stickyBeforeFollow = node.stickyScroll
const scrollTopCompensation = safeSignedGeometry(node.scrollTopCompensation)
// Compensation is additive and one-shot. Positional bottom-follow
// must judge where the viewport was before the adjustment; otherwise
// a near-tail manual viewport can cross prevMaxScroll solely because
// an above-row grew and be mistaken for an intentional bottom pin.
const scrollTopBeforeCompensation = safeUnsignedGeometry(scrollTopBeforeFollow - scrollTopCompensation)
node.scrollTopCompensation = undefined
const sticky = node.stickyScroll ?? Boolean(node.attributes['stickyScroll'])
@@ -783,7 +885,11 @@ function renderNodeToOutput(
// because the user was at bottom.
const grew = scrollHeight >= prevScrollHeight
const atBottom = sticky || (grew && scrollTopBeforeFollow >= prevMaxScroll)
if (node.pendingScrollDelta !== undefined && !validSignedGeometry(node.pendingScrollDelta)) {
node.pendingScrollDelta = undefined
}
const atBottom = sticky || (grew && scrollTopBeforeCompensation >= prevMaxScroll)
if (atBottom && (node.pendingScrollDelta ?? 0) >= 0) {
node.scrollTop = maxScroll
@@ -799,7 +905,7 @@ function renderNodeToOutput(
// undefined (never set by user action) leave it alone — setting it
// would make the sticky flag sticky-by-default and lock out
// direct scrollTop writes (e.g. the alt-screen-perf test).
if (node.stickyScroll === false && scrollTopBeforeFollow >= prevMaxScroll) {
if (node.stickyScroll === false && scrollTopBeforeCompensation >= prevMaxScroll) {
node.stickyScroll = true
}
}
@@ -822,13 +928,33 @@ function renderNodeToOutput(
// (pendingScrollDelta is only set by wheel events, >>50ms after
// startup) the probe has resolved — same timing guarantee the
// wheel-accel curve relies on.
let cur = node.scrollTop ?? 0
const pending = node.pendingScrollDelta
let cur = safeUnsignedGeometry(node.scrollTop)
let pending = node.pendingScrollDelta
if (pending !== undefined && !validSignedGeometry(pending)) {
node.pendingScrollDelta = undefined
pending = undefined
}
const cMin = node.scrollClampMin
const cMax = node.scrollClampMax
const haveClamp = cMin !== undefined && cMax !== undefined
if (pending !== undefined && pending !== 0) {
const haveClamp =
cMin !== undefined &&
cMax !== undefined &&
validUnsignedGeometry(cMin) &&
validClampMaximum(cMax) &&
cMin <= cMax
if (!haveClamp && (cMin !== undefined || cMax !== undefined)) {
node.scrollClampMin = undefined
node.scrollClampMax = undefined
}
// Preserve pending user intent for the compensation paint. Draining
// resumes on the next frame; this keeps the anchor adjustment from
// being conflated with a user move at the old bottom boundary.
if (scrollTopCompensation === 0 && pending !== undefined && pending !== 0) {
// Drain continues even past the clamp — the render-clamp below
// holds the VISUAL at the mounted edge regardless. Hard-stopping
// here caused stop-start jutter: drain hits edge → pause → React
@@ -844,14 +970,18 @@ function renderNodeToOutput(
const pastClamp = haveClamp && ((pending < 0 && cur < cMin) || (pending > 0 && cur > cMax))
const eff = pastClamp ? Math.min(4, innerHeight >> 3) : innerHeight
cur += isXtermJsHost() ? drainAdaptive(node, pending, eff) : drainProportional(node, pending, eff)
} else if (pending === 0) {
const drained =
cur + (isXtermJsHost() ? drainAdaptive(node, pending, eff) : drainProportional(node, pending, eff))
cur = safeUnsignedGeometry(drained, cur)
} else if (scrollTopCompensation === 0 && pending === 0) {
// Opposite scrollBy calls cancelled to zero — clear so we don't
// schedule an infinite loop of no-op drain frames.
node.pendingScrollDelta = undefined
}
let scrollTop = Math.max(0, Math.min(cur, maxScroll))
let scrollTop = safeUnsignedGeometry(Math.max(0, Math.min(cur, maxScroll)))
// Virtual-scroll clamp: if scrollTop raced past the currently-mounted
// range (burst PageUp before React re-renders), render at the EDGE of
@@ -948,7 +1078,42 @@ function renderNodeToOutput(
const prevHeight = contentCached?.height ?? scrollHeight
const heightDelta = scrollHeight - prevHeight
const safeForFastPath = !hint || heightDelta === 0 || (hint.delta > 0 && heightDelta === hint.delta)
const heightSafeForFastPath = !hint || heightDelta === 0 || (hint.delta > 0 && heightDelta === hint.delta)
const outputWidth = Number.isSafeInteger(output.width) && output.width > 0 ? output.width : 0
const outputHeight = Number.isSafeInteger(output.height) && output.height > 0 ? output.height : 0
const fastPathBounds = (() => {
if (
!hint ||
!Number.isSafeInteger(hint.top) ||
!Number.isSafeInteger(hint.bottom) ||
!Number.isSafeInteger(hint.delta) ||
hint.delta === 0 ||
hint.top > hint.bottom ||
!Number.isFinite(x) ||
!Number.isFinite(width) ||
width < 0 ||
!Number.isFinite(childVisibleX1) ||
!Number.isFinite(childVisibleX2) ||
!Number.isFinite(childVisibleY1) ||
!Number.isFinite(childVisibleY2)
) {
return null
}
const x1 = Math.max(0, Math.floor(x), Math.floor(childVisibleX1))
const x2 = Math.min(outputWidth, Math.ceil(x + width), Math.ceil(childVisibleX2))
const top = Math.max(0, hint.top, Math.floor(childVisibleY1))
const bottom = Math.min(outputHeight, hint.bottom + 1, Math.ceil(childVisibleY2))
if (x1 >= x2 || top >= bottom || Math.abs(hint.delta) > bottom - top) {
return null
}
return { bottom, top, width: x2 - x1, x: x1 }
})()
const safeForFastPath = heightSafeForFastPath && (!hint || fastPathBounds !== null)
// Diagnostics (opt-in via scrollFastPathStats reader). Only
// counts when a hint was captured — cases where nothing scrolled
@@ -960,9 +1125,12 @@ function renderNodeToOutput(
scrollFastPathStats.lastPrevHeight = prevHeight
scrollFastPathStats.lastHeightDelta = heightDelta
if (!safeForFastPath) {
if (!heightSafeForFastPath) {
scrollFastPathStats.declined.heightDeltaMismatch++
scrollFastPathStats.lastDeclineReason = `heightDelta=${heightDelta} hintDelta=${hint.delta}`
} else if (!fastPathBounds) {
scrollFastPathStats.declined.other++
scrollFastPathStats.lastDeclineReason = 'invalidOrEmptyRepairBounds'
} else if (!prevScreen) {
scrollFastPathStats.declined.noPrevScreen++
scrollFastPathStats.lastDeclineReason = 'noPrevScreen'
@@ -979,26 +1147,19 @@ function renderNodeToOutput(
scrollHint = null
}
if (hint && prevScreen && safeForFastPath) {
const { top, bottom, delta } = hint
const w = Math.floor(width)
output.blit(prevScreen, Math.floor(x), top, w, bottom - top + 1)
output.shift(top, bottom, delta)
if (hint && prevScreen && safeForFastPath && fastPathBounds) {
const { delta } = hint
const { bottom, top, width: repairWidth, x: repairX } = fastPathBounds
const bottomInclusive = bottom - 1
// Keep the terminal hint aligned with the same bounded rows used
// to construct next.screen. Valid in-bounds hints are unchanged.
scrollHint = { bottom: bottomInclusive, delta, top }
output.blit(prevScreen, repairX, top, repairWidth, bottom - top)
output.shift(top, bottomInclusive, delta)
// Edge rows: new content entering the viewport.
const edgeTop = delta > 0 ? bottom - delta + 1 : top
const edgeBottom = delta > 0 ? bottom : top - delta - 1
output.clear({
x: Math.floor(x),
y: edgeTop,
width: w,
height: edgeBottom - edgeTop + 1
})
output.clip({
x1: undefined,
x2: undefined,
y1: edgeTop,
y2: edgeBottom + 1
})
const edgeTop = Math.max(top, delta > 0 ? bottom - delta : top)
const edgeBottom = Math.min(bottom, delta > 0 ? bottom : top - delta)
// Snapshot dirty children before the first pass — the first
// pass clears dirty flags, and edge-spanning children would be
@@ -1007,20 +1168,33 @@ function renderNodeToOutput(
? new Set(content.childNodes.filter(c => (c as DOMElement).dirty))
: null
renderScrolledChildren(
content,
output,
contentX,
contentY,
hasRemovedChild,
undefined,
// Cull to edge in child-local coords (inverse of contentY offset).
edgeTop - contentY,
edgeBottom + 1 - contentY,
boxBackgroundColor,
true
)
output.unclip()
if (edgeTop < edgeBottom) {
output.clear({
x: repairX,
y: edgeTop,
width: repairWidth,
height: edgeBottom - edgeTop
})
output.clip({ x1: repairX, x2: repairX + repairWidth, y1: edgeTop, y2: edgeBottom })
renderScrolledChildren(
content,
output,
contentX,
contentY,
hasRemovedChild,
undefined,
// Cull to edge in child-local coords (inverse of contentY offset).
edgeTop - contentY,
edgeBottom - contentY,
boxBackgroundColor,
true,
repairX,
repairX + repairWidth,
edgeTop,
edgeBottom
)
output.unclip()
}
// Second pass: re-render children in stable rows whose screen
// position doesn't match where the shift put their old pixels.
@@ -1040,8 +1214,7 @@ function renderNodeToOutput(
// path preserved.
if (dirtyChildren) {
const edgeTopLocal = edgeTop - contentY
const edgeBottomLocal = edgeBottom + 1 - contentY
const spaces = ' '.repeat(w)
const edgeBottomLocal = edgeBottom - contentY
// Track cumulative height change of children iterated so far.
// A clean child's yogaTop is unchanged iff this is zero (no
// sibling above it grew/shrank/mounted). When zero, the skip
@@ -1075,8 +1248,17 @@ function renderNodeToOutput(
continue
}
const childLeft = cy.getComputedLeft()
const childTop = cy.getComputedTop()
const childW = cy.getComputedWidth()
const childH = cy.getComputedHeight()
if (!validYogaRect(childLeft, childTop, childW, childH)) {
dropSubtreeCache(childElem)
continue
}
const childBottom = childTop + childH
if (isDirty) {
@@ -1094,7 +1276,23 @@ function renderNodeToOutput(
continue
}
const screenY = Math.floor(contentY + childTop)
const childScreenX = contentX + childLeft
const childScreenRight = childScreenX + childW
const childScreenY = contentY + childTop
const childScreenBottom = contentY + childBottom
if (
!validSignedGeometry(childScreenX) ||
!validSignedGeometry(childScreenRight) ||
!validSignedGeometry(childScreenY) ||
!validSignedGeometry(childScreenBottom)
) {
dropSubtreeCache(childElem)
continue
}
const screenY = Math.floor(childScreenY)
// Clean children reaching here have cumHeightShift ≠ 0 OR
// no cache. Re-check precisely: cached.y − delta is where
@@ -1113,28 +1311,39 @@ function renderNodeToOutput(
// Wipe this child's region with spaces to overwrite stale
// blitted content — output.clear() only expands damage and
// cannot zero cells that the blit already wrote.
const screenBottom = Math.min(
Math.floor(contentY + childBottom),
const repairChildX = Math.max(repairX, Math.floor(childScreenX))
const repairChildRight = Math.min(repairX + repairWidth, Math.ceil(childScreenRight))
const repairChildTop = Math.max(top, screenY)
const repairChildBottom = Math.min(
bottom,
Math.floor(childScreenBottom),
Math.floor((y1 ?? y) + padTop + innerHeight)
)
if (screenY < screenBottom) {
const fill = Array(screenBottom - screenY)
if (repairChildX < repairChildRight && repairChildTop < repairChildBottom) {
const spaces = ' '.repeat(repairChildRight - repairChildX)
const fill = Array(repairChildBottom - repairChildTop)
.fill(spaces)
.join('\n')
output.write(Math.floor(x), screenY, fill)
output.write(repairChildX, repairChildTop, fill)
output.clip({
x1: undefined,
x2: undefined,
y1: screenY,
y2: screenBottom
x1: repairChildX,
x2: repairChildRight,
y1: repairChildTop,
y2: repairChildBottom
})
renderNodeToOutput(childElem, output, {
offsetX: contentX,
offsetY: contentY,
prevScreen: undefined,
inheritedBackgroundColor: boxBackgroundColor
inheritedBackgroundColor: boxBackgroundColor,
visibleX1: repairChildX,
visibleX2: repairChildRight,
visibleY1: repairChildTop,
visibleY2: repairChildBottom
})
output.unclip()
}
@@ -1148,34 +1357,36 @@ function renderNodeToOutput(
// pixels sit at (rect.y - delta) — neither edge render nor the
// overlay's own re-render covers them. Wipe and re-render
// ScrollBox content so the diff writes correct cells.
const spaces = absoluteRectsPrev.length ? ' '.repeat(w) : ''
for (const r of absoluteRectsPrev) {
if (r.y >= bottom + 1 || r.y + r.height <= top) {
if (!validYogaRect(r.x, r.y, r.width, r.height)) {
continue
}
const repairOverlayX = Math.max(repairX, Math.floor(r.x))
const repairOverlayRight = Math.min(repairX + repairWidth, Math.ceil(r.x + r.width))
const shiftedTop = Math.max(top, Math.floor(r.y) - delta)
const shiftedBottom = Math.min(bottom + 1, Math.floor(r.y + r.height) - delta)
const shiftedBottom = Math.min(bottom, Math.floor(r.y + r.height) - delta)
// Skip if entirely within edge rows (already rendered).
if (shiftedTop >= edgeTop && shiftedBottom <= edgeBottom + 1) {
if (edgeTop < edgeBottom && shiftedTop >= edgeTop && shiftedBottom <= edgeBottom) {
continue
}
if (shiftedTop >= shiftedBottom) {
if (repairOverlayX >= repairOverlayRight || shiftedTop >= shiftedBottom) {
continue
}
const spaces = ' '.repeat(repairOverlayRight - repairOverlayX)
const fill = Array(shiftedBottom - shiftedTop)
.fill(spaces)
.join('\n')
output.write(Math.floor(x), shiftedTop, fill)
output.write(repairOverlayX, shiftedTop, fill)
output.clip({
x1: undefined,
x2: undefined,
x1: repairOverlayX,
x2: repairOverlayRight,
y1: shiftedTop,
y2: shiftedBottom
})
@@ -1189,7 +1400,11 @@ function renderNodeToOutput(
shiftedTop - contentY,
shiftedBottom - contentY,
boxBackgroundColor,
true
true,
repairOverlayX,
repairOverlayRight,
shiftedTop,
shiftedBottom
)
output.unclip()
}
@@ -1233,7 +1448,12 @@ function renderNodeToOutput(
scrolled || positionChanged ? undefined : prevScreen,
scrollTop,
scrollTop + innerHeight,
boxBackgroundColor
boxBackgroundColor,
false,
childVisibleX1,
childVisibleX2,
childVisibleY1,
childVisibleY2
)
}
@@ -1264,14 +1484,23 @@ function renderNodeToOutput(
const innerHeight = Math.floor(height) - borderTop - borderBottom
if (innerWidth > 0 && innerHeight > 0) {
const spaces = ' '.repeat(innerWidth)
const fillX1 = Math.max(0, Math.floor(x + borderLeft))
const fillX2 = Math.min(output.width, Math.ceil(x + width - borderRight))
const fillY1 = Math.max(childVisibleY1, Math.floor(y + borderTop))
const fillY2 = Math.min(childVisibleY2, Math.ceil(y + height - borderBottom))
const fillWidth = Math.max(0, fillX2 - fillX1)
const fillHeight = Math.max(0, fillY2 - fillY1)
const spaces = ' '.repeat(fillWidth)
const fillLine = ownBackgroundColor
? applyTextStyles(spaces, { backgroundColor: ownBackgroundColor })
: spaces
const fill = Array(innerHeight).fill(fillLine).join('\n')
output.write(x + borderLeft, y + borderTop, fill)
if (fillWidth > 0 && fillHeight > 0) {
const fill = Array(fillHeight).fill(fillLine).join('\n')
output.write(fillX1, fillY1, fill)
}
}
}
@@ -1289,7 +1518,11 @@ function renderNodeToOutput(
// valid composite, but children CAN reposition (ScrollBox remeasure
// on re-render → /permissions body blanked on Down arrow, #25436).
ownBackgroundColor || node.style.opaque ? undefined : prevScreen,
boxBackgroundColor
boxBackgroundColor,
childVisibleX1,
childVisibleX2,
childVisibleY1,
childVisibleY2
)
}
@@ -1300,9 +1533,21 @@ function renderNodeToOutput(
// Render border AFTER children to ensure it's not overwritten by child
// clearing operations. When a child shrinks, it clears its old area,
// which may overlap with where the parent's border now is.
renderBorder(x, y, node, output)
renderBorder(x, y, node, output, activeVisibleX1, activeVisibleX2, activeVisibleY1, activeVisibleY2)
} else if (node.nodeName === 'ink-root') {
renderChildren(node, output, x, y, hasRemovedChild, prevScreen, inheritedBackgroundColor)
renderChildren(
node,
output,
x,
y,
hasRemovedChild,
prevScreen,
inheritedBackgroundColor,
activeVisibleX1,
activeVisibleX2,
activeVisibleY1,
activeVisibleY2
)
}
// Cache layout bounds for dirty tracking
@@ -1352,7 +1597,11 @@ function renderChildren(
offsetY: number,
hasRemovedChild: boolean,
prevScreen: Screen | undefined,
inheritedBackgroundColor: Color | undefined
inheritedBackgroundColor: Color | undefined,
visibleX1: number,
visibleX2: number,
visibleY1: number,
visibleY2: number
): void {
let seenDirtyChild = false
let seenDirtyClipped = false
@@ -1370,7 +1619,11 @@ function renderChildren(
// the opaque/bg reads don't happen per-child per-frame.
skipSelfBlit:
seenDirtyClipped && isAbsolute && !childElem.style.opaque && childElem.style.backgroundColor === undefined,
inheritedBackgroundColor
inheritedBackgroundColor,
visibleX1,
visibleX2,
visibleY1,
visibleY2
})
if (wasDirty && !seenDirtyChild) {
@@ -1499,7 +1752,11 @@ function renderScrolledChildren(
// When true (DECSTBM fast path), culled children keep their cache —
// the blit+shift put stable rows in next.screen so stale cache is
// never read. Avoids walking O(total_children * subtree_depth) per frame.
preserveCulledCache = false
preserveCulledCache = false,
visibleX1 = 0,
visibleX2 = output.width,
visibleY1 = 0,
visibleY2 = output.height
): void {
let seenDirtyChild = false
// Track cumulative height shift of dirty children iterated so far. When
@@ -1527,6 +1784,14 @@ function renderScrolledChildren(
top = cy.getComputedTop()
height = cy.getComputedHeight()
const bottom = top + height
if (!validSignedGeometry(top) || !validYogaDimension(height) || !validSignedGeometry(bottom) || bottom < top) {
dropSubtreeCache(childElem)
continue
}
if (childElem.dirty) {
cumHeightShift += height - (cached ? cached.height : 0)
}
@@ -1542,6 +1807,12 @@ function renderScrolledChildren(
const bottom = top + height
if (!validSignedGeometry(top) || !validYogaDimension(height) || !validSignedGeometry(bottom) || bottom < top) {
dropSubtreeCache(childElem)
continue
}
if (bottom <= scrollTopY || top >= scrollBottomY) {
// Culled — outside visible window. Drop stale cache entries from
// the subtree so when this child re-enters it doesn't fire clears
@@ -1560,7 +1831,11 @@ function renderScrolledChildren(
offsetX,
offsetY,
prevScreen: hasRemovedChild || seenDirtyChild ? undefined : prevScreen,
inheritedBackgroundColor
inheritedBackgroundColor,
visibleX1,
visibleX2,
visibleY1: Math.max(visibleY1, offsetY + scrollTopY),
visibleY2: Math.min(visibleY2, offsetY + scrollBottomY)
})
if (wasDirty) {

View File

@@ -0,0 +1,483 @@
// Deterministic virtual-history benchmark. The file intentionally uses only
// APIs present before the performance candidate so the exact same script can
// be copied/run on base and candidate checkouts.
//
// Run from ui-tui:
// npx tsx scripts/bench-history-scroll.tsx
// npx tsx scripts/bench-history-scroll.tsx --warmups=2 --samples=5 --items=100,1000,10000
//
// In addition to the virtual-history workloads, every run mounts one
// oversized bordered/fill box at each RENDERER_EXTENT inside the fixed
// viewport. Keeping that tree to a few Yoga nodes isolates renderer clipping
// from node-construction cost and makes the workload revision-comparable.
import { PassThrough } from 'stream'
import { Box, renderSync, ScrollBox, type ScrollBoxHandle, Text } from '@hermes/ink'
import React, { useLayoutEffect, useRef } from 'react'
import { useVirtualHistory } from '../src/hooks/useVirtualHistory.js'
const DEFAULT_WORKLOADS = [100, 1_000, 10_000]
const RENDERER_EXTENTS = [100, 1_000, 10_000]
const DEFAULT_WARMUPS = 1
const DEFAULT_SAMPLES = 5
const COLUMNS = 100
const ROWS = 30
const MAX_MOUNTED = 120
interface BenchItem {
height: number
key: string
text: string
}
interface Exposed {
scroll: ScrollBoxHandle | null
virtual: ReturnType<typeof useVirtualHistory>
}
interface Sample {
anchorError: number
heapDeltaBytes: number | null
invalidOffsets: number
measuredHeightReconciliationMs: number
mountMs: number
mountedRowsMax: number
nonMonotoneOffsets: number
rerenderMs: number
scrollMs: number
terminalBytes: number
terminalWrites: number
}
interface WorkloadResult {
distributions: {
anchorError: ReturnType<typeof distribution>
heapDeltaBytes: ReturnType<typeof distribution>
measuredHeightReconciliationMs: ReturnType<typeof distribution>
mountMs: ReturnType<typeof distribution>
mountedRowsMax: ReturnType<typeof distribution>
rerenderMs: ReturnType<typeof distribution>
scrollMs: ReturnType<typeof distribution>
terminalBytes: ReturnType<typeof distribution>
terminalWrites: ReturnType<typeof distribution>
}
invalidOffsets: number
itemCount: number
nonMonotoneOffsets: number
samples: Sample[]
}
interface OversizedRendererSample {
freshMountRenderMs: number
terminalBytes: number
terminalWrites: number
}
interface OversizedRendererResult {
distributions: {
freshMountRenderMs: ReturnType<typeof distribution>
terminalBytes: ReturnType<typeof distribution>
terminalWrites: ReturnType<typeof distribution>
}
extent: number
samples: OversizedRendererSample[]
}
class CountingStream extends PassThrough {
columns = COLUMNS
rows = ROWS
isTTY = false
bytes = 0
writes = 0
override _write(chunk: Buffer | string, encoding: BufferEncoding, callback: (error?: Error | null) => void) {
this.bytes += Buffer.byteLength(chunk)
this.writes++
callback()
}
}
const immediate = () => new Promise<void>(resolve => setImmediate(resolve))
async function settle(frames = 4) {
for (let frame = 0; frame < frames; frame++) {
await immediate()
}
}
async function waitUntil(predicate: () => boolean, attempts = 40) {
for (let attempt = 0; attempt < attempts; attempt++) {
if (predicate()) {
return true
}
await immediate()
}
return predicate()
}
function makeItems(count: number): BenchItem[] {
return Array.from({ length: count }, (_, index) => ({
height: 1 + ((index * 17) % 4),
key: `row-${index}`,
text: `row ${index} ${'history '.repeat(2 + (index % 5))}`
}))
}
function Harness({ expose, items }: { expose: React.MutableRefObject<Exposed | null>; items: readonly BenchItem[] }) {
const scrollRef = useRef<ScrollBoxHandle | null>(null)
const virtual = useVirtualHistory(scrollRef, items, COLUMNS, {
coldStartCount: 30,
estimateHeight: index => items[index]?.height ?? 1,
maxMounted: MAX_MOUNTED,
overscan: 20
})
useLayoutEffect(() => {
expose.current = { scroll: scrollRef.current, virtual }
})
return (
<ScrollBox flexDirection="column" height={ROWS} ref={scrollRef} stickyScroll>
<Box flexDirection="column" width="100%">
{virtual.topSpacer > 0 ? <Box height={virtual.topSpacer} /> : null}
{items.slice(virtual.start, virtual.end).map(item => (
<Box height={item.height} key={item.key} ref={virtual.measureRef(item.key)}>
<Text>{item.text}</Text>
</Box>
))}
{virtual.bottomSpacer > 0 ? <Box height={virtual.bottomSpacer} /> : null}
</Box>
</ScrollBox>
)
}
function OversizedRendererHarness({ extent }: { extent: number }) {
return (
<ScrollBox flexDirection="column" height={ROWS} width={COLUMNS}>
<Box backgroundColor="ansi:blue" borderStyle="single" flexShrink={0} height={extent} opaque width={COLUMNS}>
<Text>deterministic oversized height workload</Text>
</Box>
<Box backgroundColor="ansi:magenta" borderStyle="single" height={ROWS} opaque position="absolute" width={extent}>
<Text>deterministic oversized width workload</Text>
</Box>
</ScrollBox>
)
}
function inspectOffsets(offsets: ArrayLike<number>, count: number) {
let invalidOffsets = 0
let nonMonotoneOffsets = 0
for (let index = 0; index <= count; index++) {
const value = offsets[index]
if (!Number.isFinite(value)) {
invalidOffsets++
}
if (index > 0 && value! < offsets[index - 1]!) {
nonMonotoneOffsets++
}
}
return { invalidOffsets, nonMonotoneOffsets }
}
async function runSample(itemCount: number): Promise<Sample> {
const stdout = new CountingStream()
const stderr = new CountingStream()
const stdin = new PassThrough()
const expose = { current: null as Exposed | null }
let items = makeItems(itemCount)
const heapBefore = process.memoryUsage?.().heapUsed ?? null
const mountStart = performance.now()
const instance = renderSync(<Harness expose={expose} items={items} />, {
patchConsole: false,
stderr: stderr as unknown as NodeJS.WriteStream,
stdin: stdin as unknown as NodeJS.ReadStream,
stdout: stdout as unknown as NodeJS.WriteStream
})
await waitUntil(() => expose.current?.scroll !== null)
await settle()
const mountMs = performance.now() - mountStart
let mountedRowsMax = expose.current!.virtual.end - expose.current!.virtual.start
const rerenderItems = items.map((item, index) =>
index === items.length - 1 ? { ...item, text: `${item.text} rerender` } : item
)
const rerenderStart = performance.now()
instance.rerender(<Harness expose={expose} items={rerenderItems} />)
await settle()
const rerenderMs = performance.now() - rerenderStart
items = rerenderItems
mountedRowsMax = Math.max(mountedRowsMax, expose.current!.virtual.end - expose.current!.virtual.start)
const scroll = expose.current!.scroll!
const total = expose.current!.virtual.offsets[itemCount] ?? 0
const scrollStart = performance.now()
scroll.scrollTo(Math.max(0, Math.floor(total * 0.55)))
await settle(8)
const scrollMs = performance.now() - scrollStart
mountedRowsMax = Math.max(mountedRowsMax, expose.current!.virtual.end - expose.current!.virtual.start)
const beforeOffsets = expose.current!.virtual.offsets
const beforeTop = scroll.getScrollTop()
let measuredIndex = expose.current!.virtual.start
while (measuredIndex + 1 < expose.current!.virtual.end && (beforeOffsets[measuredIndex + 1] ?? 0) > beforeTop) {
measuredIndex++
}
if ((beforeOffsets[measuredIndex + 1] ?? Number.POSITIVE_INFINITY) > beforeTop) {
measuredIndex = Math.max(expose.current!.virtual.start, measuredIndex - 1)
}
const heightDelta = 3
const oldTotal = beforeOffsets[itemCount] ?? 0
const measuredItems = items.map((item, index) =>
index === measuredIndex ? { ...item, height: item.height + heightDelta } : item
)
const reconcileStart = performance.now()
instance.rerender(<Harness expose={expose} items={measuredItems} />)
await waitUntil(() => (expose.current!.virtual.offsets[itemCount] ?? 0) === oldTotal + heightDelta)
await settle(2)
const measuredHeightReconciliationMs = performance.now() - reconcileStart
const measuredWasAbove = (beforeOffsets[measuredIndex + 1] ?? 0) <= beforeTop
const expectedTop = beforeTop + (measuredWasAbove ? heightDelta : 0)
const anchorError = Math.abs(scroll.getScrollTop() - expectedTop)
mountedRowsMax = Math.max(mountedRowsMax, expose.current!.virtual.end - expose.current!.virtual.start)
const offsetHealth = inspectOffsets(expose.current!.virtual.offsets, itemCount)
const heapAfter = process.memoryUsage?.().heapUsed ?? null
const heapDeltaBytes = heapBefore === null || heapAfter === null ? null : heapAfter - heapBefore
const terminalBytes = stdout.bytes
const terminalWrites = stdout.writes
instance.unmount()
instance.cleanup()
stdin.destroy()
stdout.destroy()
stderr.destroy()
return {
anchorError,
heapDeltaBytes,
...offsetHealth,
measuredHeightReconciliationMs,
mountMs,
mountedRowsMax,
rerenderMs,
scrollMs,
terminalBytes,
terminalWrites
}
}
async function runOversizedRendererSample(extent: number): Promise<OversizedRendererSample> {
const stdout = new CountingStream()
const stderr = new CountingStream()
const stdin = new PassThrough()
const mountStart = performance.now()
let instance: ReturnType<typeof renderSync> | undefined
try {
instance = renderSync(<OversizedRendererHarness extent={extent} />, {
patchConsole: false,
stderr: stderr as unknown as NodeJS.WriteStream,
stdin: stdin as unknown as NodeJS.ReadStream,
stdout: stdout as unknown as NodeJS.WriteStream
})
const rendered = await waitUntil(() => stdout.writes > 0)
if (!rendered) {
throw new Error(`oversized renderer extent ${extent} did not produce a terminal frame`)
}
return {
freshMountRenderMs: performance.now() - mountStart,
terminalBytes: stdout.bytes,
terminalWrites: stdout.writes
}
} finally {
instance?.unmount()
instance?.cleanup()
stdin.destroy()
stdout.destroy()
stderr.destroy()
}
}
function distribution(values: number[]) {
const sorted = [...values].sort((a, b) => a - b)
const percentile = (p: number) =>
sorted[Math.min(sorted.length - 1, Math.max(0, Math.ceil(sorted.length * p) - 1))] ?? 0
return {
max: sorted.at(-1) ?? 0,
mean: sorted.reduce((sum, value) => sum + value, 0) / Math.max(1, sorted.length),
min: sorted[0] ?? 0,
p50: percentile(0.5),
p95: percentile(0.95),
p99: percentile(0.99)
}
}
function numericArg(name: string, fallback: number) {
const raw = process.argv
.slice(2)
.find(arg => arg.startsWith(`--${name}=`))
?.split('=', 2)[1]
const parsed = Number(raw)
return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : fallback
}
function workloadsArg() {
const raw = process.argv
.slice(2)
.find(arg => arg.startsWith('--items='))
?.split('=', 2)[1]
if (!raw) {
return DEFAULT_WORKLOADS
}
const parsed = raw.split(',').map(Number)
if (parsed.some(value => !Number.isSafeInteger(value) || value <= 0)) {
throw new Error(`invalid --items workload list: ${raw}`)
}
return parsed
}
async function main() {
const workloads = workloadsArg()
const warmups = numericArg('warmups', DEFAULT_WARMUPS)
const samplesPerWorkload = numericArg('samples', DEFAULT_SAMPLES)
const results: WorkloadResult[] = []
const oversizedRendererResults: OversizedRendererResult[] = []
for (const itemCount of workloads) {
for (let warmup = 0; warmup < warmups; warmup++) {
await runSample(itemCount)
}
const samples: Sample[] = []
for (let sample = 0; sample < samplesPerWorkload; sample++) {
samples.push(await runSample(itemCount))
}
results.push({
itemCount,
distributions: {
anchorError: distribution(samples.map(sample => sample.anchorError)),
heapDeltaBytes: distribution(samples.flatMap(sample => sample.heapDeltaBytes ?? [])),
measuredHeightReconciliationMs: distribution(samples.map(sample => sample.measuredHeightReconciliationMs)),
mountMs: distribution(samples.map(sample => sample.mountMs)),
mountedRowsMax: distribution(samples.map(sample => sample.mountedRowsMax)),
rerenderMs: distribution(samples.map(sample => sample.rerenderMs)),
scrollMs: distribution(samples.map(sample => sample.scrollMs)),
terminalBytes: distribution(samples.map(sample => sample.terminalBytes)),
terminalWrites: distribution(samples.map(sample => sample.terminalWrites))
},
invalidOffsets: samples.reduce((sum, sample) => sum + sample.invalidOffsets, 0),
nonMonotoneOffsets: samples.reduce((sum, sample) => sum + sample.nonMonotoneOffsets, 0),
samples
})
}
for (const extent of RENDERER_EXTENTS) {
for (let warmup = 0; warmup < warmups; warmup++) {
await runOversizedRendererSample(extent)
}
const samples: OversizedRendererSample[] = []
for (let sample = 0; sample < samplesPerWorkload; sample++) {
samples.push(await runOversizedRendererSample(extent))
}
oversizedRendererResults.push({
distributions: {
freshMountRenderMs: distribution(samples.map(sample => sample.freshMountRenderMs)),
terminalBytes: distribution(samples.map(sample => sample.terminalBytes)),
terminalWrites: distribution(samples.map(sample => sample.terminalWrites))
},
extent,
samples
})
}
const scaling = results.slice(1).map((result, index) => {
const previous = results[index]!
const ratio = (metric: keyof (typeof result)['distributions']) =>
result.distributions[metric].p50 / Math.max(Number.EPSILON, previous.distributions[metric].p50)
return {
fromItems: previous.itemCount,
itemFactor: result.itemCount / previous.itemCount,
measuredHeightReconciliationP50Factor: ratio('measuredHeightReconciliationMs'),
mountP50Factor: ratio('mountMs'),
rerenderP50Factor: ratio('rerenderMs'),
scrollP50Factor: ratio('scrollMs'),
terminalBytesP50Factor: ratio('terminalBytes'),
toItems: result.itemCount
}
})
const oversizedRendererScaling = oversizedRendererResults.slice(1).map((result, index) => {
const previous = oversizedRendererResults[index]!
const ratio = (metric: keyof (typeof result)['distributions']) =>
result.distributions[metric].p50 / Math.max(Number.EPSILON, previous.distributions[metric].p50)
return {
extentFactor: result.extent / previous.extent,
freshMountRenderP50Factor: ratio('freshMountRenderMs'),
fromExtent: previous.extent,
terminalBytesP50Factor: ratio('terminalBytes'),
terminalWritesP50Factor: ratio('terminalWrites'),
toExtent: result.extent
}
})
process.stdout.write(
`${JSON.stringify(
{
config: { columns: COLUMNS, maxMounted: MAX_MOUNTED, rows: ROWS, samples: samplesPerWorkload, warmups },
oversizedRenderer: {
extents: RENDERER_EXTENTS,
results: oversizedRendererResults,
scaling: oversizedRendererScaling
},
results,
scaling,
workloads
},
null,
2
)}\n`
)
}
await main()

View File

@@ -5,8 +5,9 @@ import React from 'react'
import { describe, expect, it } from 'vitest'
import { MessageLine } from '../components/messageLine.js'
import { MAX_HISTORY } from '../config/limits.js'
import { toTranscriptMessages } from '../domain/messages.js'
import { upsert } from '../lib/messages.js'
import { capTranscriptHistory, upsert } from '../lib/messages.js'
import { stripAnsi } from '../lib/text.js'
import { DEFAULT_THEME } from '../theme.js'
@@ -148,3 +149,16 @@ describe('upsert', () => {
expect(prev).toHaveLength(1)
})
})
describe('capTranscriptHistory', () => {
it('keeps the intro and the newest bounded display rows', () => {
const intro = { kind: 'intro' as const, role: 'system' as const, text: '' }
const rows = Array.from({ length: 1_005 }, (_, index) => ({ role: 'user' as const, text: `m${index}` }))
const capped = capTranscriptHistory([intro, ...rows])
expect(capped).toHaveLength(MAX_HISTORY)
expect(capped[0]).toBe(intro)
expect(capped[1]?.text).toBe(`m${rows.length - (MAX_HISTORY - 1)}`)
expect(capped.at(-1)?.text).toBe('m1004')
})
})

View File

@@ -13,12 +13,13 @@ function makeScroll(overrides: Partial<Record<string, unknown>> = {}) {
getViewportHeight: vi.fn(() => 20),
getViewportTop: vi.fn(() => 0),
scrollBy: vi.fn(),
scrollTo: vi.fn(),
...overrides
}
}
describe('scrollWithSelectionBy', () => {
it('clamps to the actual remaining scroll distance before calling scrollBy', () => {
it('commits the clamped target directly instead of queueing a scroll delta', () => {
const s = makeScroll({
getScrollHeight: vi.fn(() => 30),
getScrollTop: vi.fn(() => 9),
@@ -34,7 +35,8 @@ describe('scrollWithSelectionBy', () => {
scrollWithSelectionBy(10, { scrollRef: { current: s as never }, selection })
expect(s.scrollBy).toHaveBeenCalledWith(1)
expect(s.scrollTo).toHaveBeenCalledWith(10)
expect(s.scrollBy).not.toHaveBeenCalled()
})
it('uses fresh scroll height when cached height would swallow a down-scroll at a fake bottom', () => {
@@ -54,7 +56,8 @@ describe('scrollWithSelectionBy', () => {
scrollWithSelectionBy(10, { scrollRef: { current: s as never }, selection })
expect(s.scrollBy).toHaveBeenCalledWith(4)
expect(s.scrollTo).toHaveBeenCalledWith(14)
expect(s.scrollBy).not.toHaveBeenCalled()
})
it('uses fresh height when pending down-scroll reaches the cached fake bottom', () => {
@@ -75,7 +78,8 @@ describe('scrollWithSelectionBy', () => {
scrollWithSelectionBy(10, { scrollRef: { current: s as never }, selection })
expect(s.scrollBy).toHaveBeenCalledWith(6)
expect(s.scrollTo).toHaveBeenCalledWith(18)
expect(s.scrollBy).not.toHaveBeenCalled()
})
it('does nothing at the edge instead of queueing dead pending deltas', () => {
@@ -94,6 +98,29 @@ describe('scrollWithSelectionBy', () => {
scrollWithSelectionBy(10, { scrollRef: { current: s as never }, selection })
expect(s.scrollTo).not.toHaveBeenCalled()
expect(s.scrollBy).not.toHaveBeenCalled()
})
it('preserves selection capture and shifting on the direct path', () => {
const s = makeScroll({
getScrollTop: vi.fn(() => 10),
getViewportHeight: vi.fn(() => 20),
getViewportTop: vi.fn(() => 5)
})
const selection = {
captureScrolledRows: vi.fn(),
getState: vi.fn(() => ({ anchor: { row: 10 }, focus: { row: 12 } })),
shiftAnchor: vi.fn(),
shiftSelection: vi.fn()
}
scrollWithSelectionBy(3, { scrollRef: { current: s as never }, selection })
expect(selection.captureScrolledRows).toHaveBeenCalledWith(5, 7, 'above')
expect(selection.shiftSelection).toHaveBeenCalledWith(-3, 5, 24)
expect(s.scrollTo).toHaveBeenCalledWith(13)
expect(s.scrollBy).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,627 @@
import { PassThrough } from 'stream'
import { Box, renderSync, ScrollBox, type ScrollBoxHandle, Text } from '@hermes/ink'
import React, { useLayoutEffect, useRef } from 'react'
import { describe, expect, it, vi } from 'vitest'
import SourceBox from '../../packages/hermes-ink/src/ink/components/Box.js'
import SourceScrollBox from '../../packages/hermes-ink/src/ink/components/ScrollBox.js'
import SourceText from '../../packages/hermes-ink/src/ink/components/Text.js'
import type { DOMElement } from '../../packages/hermes-ink/src/ink/dom.js'
import Output from '../../packages/hermes-ink/src/ink/output.js'
import { scrollFastPathStats as sourceScrollFastPathStats } from '../../packages/hermes-ink/src/ink/render-node-to-output.js'
import { renderSync as renderSourceSync } from '../../packages/hermes-ink/src/ink/root.js'
import { useVirtualHistory } from '../hooks/useVirtualHistory.js'
interface Item {
height: number
heightAfterResize?: number
key: string
text?: string
}
interface Exposed {
scroll: ScrollBoxHandle | null
virtualHistory: ReturnType<typeof useVirtualHistory>
}
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
const makeStreams = () => {
const stdout = new PassThrough()
const stdin = new PassThrough()
const stderr = new PassThrough()
Object.assign(stdout, { columns: 80, isTTY: false, rows: 20 })
Object.assign(stdin, { isTTY: false })
Object.assign(stderr, { isTTY: false })
stdout.on('data', () => {})
return { stderr, stdin, stdout }
}
const itemHeightForColumns = (item: Item | undefined, columns: number) =>
columns >= 80 ? (item?.heightAfterResize ?? item?.height ?? 1) : (item?.height ?? 1)
function Harness({
columns = 80,
expose,
height = 10,
generation = 0,
initialHeights,
items,
maxMounted = 16
}: {
columns?: number
expose: React.MutableRefObject<Exposed | null>
height?: number
generation?: number
initialHeights?: ReadonlyMap<string, number>
items: readonly Item[]
maxMounted?: number
}) {
const scrollRef = useRef<ScrollBoxHandle | null>(null)
const virtualHistory = useVirtualHistory(scrollRef, items, columns, {
coldStartCount: 16,
estimateHeight: index => itemHeightForColumns(items[index], columns),
generation,
initialHeights,
maxMounted,
overscan: 2
})
useLayoutEffect(() => {
expose.current = { scroll: scrollRef.current, virtualHistory }
})
return React.createElement(
ScrollBox,
{ flexDirection: 'column', height, ref: scrollRef, stickyScroll: true },
React.createElement(
Box,
{ flexDirection: 'column', width: '100%' },
virtualHistory.topSpacer > 0 ? React.createElement(Box, { height: virtualHistory.topSpacer }) : null,
...items.slice(virtualHistory.start, virtualHistory.end).map(item =>
React.createElement(
Box,
{
height: itemHeightForColumns(item, columns),
key: item.key,
ref: virtualHistory.measureRef(item.key)
},
React.createElement(Text, null, item.text ?? item.key)
)
),
virtualHistory.bottomSpacer > 0 ? React.createElement(Box, { height: virtualHistory.bottomSpacer }) : null
)
)
}
function CorruptGeometryHarness({ expose, tick }: { expose: React.MutableRefObject<DOMElement[]>; tick: number }) {
const nodes = useRef<DOMElement[]>([])
useLayoutEffect(() => {
expose.current = nodes.current
})
return React.createElement(
ScrollBox,
{ flexDirection: 'column', height: 8 },
...['nan-top', 'positive-infinity', 'negative-infinity', 'billion-rows', 'clipped-huge-fill'].map((label, index) =>
React.createElement(
Box,
{
backgroundColor: 'blue',
borderStyle: label === 'clipped-huge-fill' ? 'single' : undefined,
height: 1,
key: label,
opaque: true,
ref: node => {
if (node) {
nodes.current[index] = node
}
},
width: '100%'
},
React.createElement(Text, null, `${label}-${tick}`)
)
),
React.createElement(
Box,
{
height: 1,
ref: node => {
if (node) {
nodes.current[5] = node
}
}
},
React.createElement(Text, null, React.createElement(Text, null, `nested-corrupt-${tick}`))
),
React.createElement(Text, null, `adjacent-valid-${tick}`),
React.createElement(Box, { height: 20 }, React.createElement(Text, null, `tail-${tick}`))
)
}
interface FastPathRepairExpose {
adjacent: DOMElement | null
dirtyChild: DOMElement | null
overlay: DOMElement | null
scroll: ScrollBoxHandle | null
scrollBox: DOMElement | null
}
function FastPathRepairHarness({
expose,
tick,
dirtyTick = tick,
includeOverlay = true
}: {
dirtyTick?: number
expose: React.MutableRefObject<FastPathRepairExpose | null>
includeOverlay?: boolean
tick: number
}) {
return React.createElement(
SourceBox,
{ flexDirection: 'column', height: 12, width: 40 },
React.createElement(
SourceScrollBox,
{
flexDirection: 'column',
height: 8,
ref: scroll => {
if (expose.current) {
expose.current.scroll = scroll
}
},
width: 40
},
React.createElement(SourceBox, { height: 2 }, React.createElement(SourceText, null, 'head-row')),
React.createElement(
SourceBox,
{
height: 2,
ref: dirtyChild => {
if (expose.current) {
expose.current.dirtyChild = dirtyChild
expose.current.scrollBox = dirtyChild?.parentNode?.parentNode ?? null
}
}
},
React.createElement(SourceText, null, `dirty-row-${dirtyTick}`)
),
React.createElement(SourceBox, { height: 20 }, React.createElement(SourceText, null, 'tail-row'))
),
includeOverlay
? React.createElement(
SourceBox,
{
height: 2,
left: 0,
position: 'absolute',
ref: overlay => {
if (expose.current) {
expose.current.overlay = overlay
}
},
top: 2,
width: 40
},
React.createElement(SourceText, null, 'overlay-row')
)
: null,
React.createElement(
SourceBox,
{
height: 1,
ref: adjacent => {
if (expose.current) {
expose.current.adjacent = adjacent
}
}
},
React.createElement(SourceText, null, `adjacent-fast-path-${tick}`)
)
)
}
function guardFastPathRepairAllocations(maxWidth: number, maxHeight: number) {
const originalArrayFill = Array.prototype.fill
const originalBlit = Output.prototype.blit
const originalClear = Output.prototype.clear
const originalRepeat = String.prototype.repeat
const originalWrite = Output.prototype.write
const observed = {
largestArrayRows: 0,
largestBlitHeight: 0,
largestBlitWidth: 0,
largestClearHeight: 0,
largestClearWidth: 0,
largestRepeat: 0,
largestWrite: 0,
repairWhitespaceWrites: 0
}
vi.spyOn(String.prototype, 'repeat').mockImplementation(function (count: number) {
observed.largestRepeat = Math.max(observed.largestRepeat, count)
if (!Number.isSafeInteger(count) || count < 0 || count > maxWidth) {
throw new Error(`unbounded fast-path repeat: ${count}`)
}
return originalRepeat.call(this, count)
})
vi.spyOn(Array.prototype, 'fill').mockImplementation(function (
this: unknown[],
value: unknown,
start?: number,
end?: number
) {
observed.largestArrayRows = Math.max(observed.largestArrayRows, this.length)
if (this.length > maxHeight) {
throw new Error(`unbounded fast-path row array: ${this.length}`)
}
return Reflect.apply(originalArrayFill, this, [value, start, end])
} as typeof Array.prototype.fill)
vi.spyOn(Output.prototype, 'blit').mockImplementation(function (...args: Parameters<Output['blit']>) {
observed.largestBlitWidth = Math.max(observed.largestBlitWidth, args[3])
observed.largestBlitHeight = Math.max(observed.largestBlitHeight, args[4])
return originalBlit.apply(this, args)
})
vi.spyOn(Output.prototype, 'clear').mockImplementation(function (...args: Parameters<Output['clear']>) {
observed.largestClearWidth = Math.max(observed.largestClearWidth, args[0].width)
observed.largestClearHeight = Math.max(observed.largestClearHeight, args[0].height)
return originalClear.apply(this, args)
})
vi.spyOn(Output.prototype, 'write').mockImplementation(function (...args: Parameters<Output['write']>) {
observed.largestWrite = Math.max(observed.largestWrite, args[2].length)
if (args[2].length > 0 && /^[ \n]+$/.test(args[2])) {
observed.repairWhitespaceWrites++
}
if (args[2].length > maxWidth * maxHeight + maxHeight) {
throw new Error(`unbounded fast-path Output.write input: ${args[2].length}`)
}
return originalWrite.apply(this, args)
})
return observed
}
describe('ScrollBox renderer bounds', () => {
it('rejects invalid imperative geometry without poisoning scroll state', async () => {
const items = Array.from({ length: 20 }, (_, index) => ({ height: 2, key: `item-${index}` }))
const expose = { current: null as Exposed | null }
const streams = makeStreams()
const instance = renderSync(React.createElement(Harness, { expose, items }), {
patchConsole: false,
stderr: streams.stderr as NodeJS.WriteStream,
stdin: streams.stdin as NodeJS.ReadStream,
stdout: streams.stdout as NodeJS.WriteStream
})
try {
await delay(20)
const scroll = expose.current!.scroll!
scroll.scrollTo(4)
scroll.scrollTo(Number.NaN)
scroll.scrollBy(Number.POSITIVE_INFINITY)
scroll.adjustScrollTop(Number.NEGATIVE_INFINITY)
scroll.setClampBounds(Number.NaN, Number.POSITIVE_INFINITY)
await delay(20)
expect(scroll.getScrollTop()).toBe(4)
expect(scroll.getPendingDelta()).toBe(0)
expect(Number.isFinite(scroll.getScrollHeight())).toBe(true)
} finally {
instance.unmount()
instance.cleanup()
}
})
it('fails closed on corrupt ScrollBox child geometry and keeps adjacent rows renderable', async () => {
const expose = { current: [] as DOMElement[] }
const streams = makeStreams()
const originalRepeat = String.prototype.repeat
const originalWrite = Output.prototype.write
let largestWriteInput = 0
let largestWrite = 0
let output = ''
vi.spyOn(String.prototype, 'repeat').mockImplementation(function (count: number) {
if (!Number.isSafeInteger(count) || count < 0 || count > 10_000) {
throw new Error(`unbounded string repeat: ${count}`)
}
return originalRepeat.call(this, count)
})
vi.spyOn(Output.prototype, 'write').mockImplementation(function (...args: Parameters<Output['write']>) {
largestWriteInput = Math.max(largestWriteInput, args[2].length)
if (args[2].length > 10_000) {
throw new Error(`unbounded Output.write input: ${args[2].length}`)
}
return originalWrite.apply(this, args)
})
streams.stdout.removeAllListeners('data')
streams.stdout.on('data', chunk => {
largestWrite = Math.max(largestWrite, chunk.length)
output += chunk.toString()
})
const instance = renderSync(React.createElement(CorruptGeometryHarness, { expose, tick: 0 }), {
patchConsole: false,
stderr: streams.stderr as NodeJS.WriteStream,
stdin: streams.stdin as NodeJS.ReadStream,
stdout: streams.stdout as NodeJS.WriteStream
})
try {
await delay(20)
const [nanTop, positiveInfinity, negativeInfinity, billionRows, clippedHugeFill, nestedTextWrapper] =
expose.current
expect(nanTop?.yogaNode).toBeDefined()
expect(positiveInfinity?.yogaNode).toBeDefined()
expect(negativeInfinity?.yogaNode).toBeDefined()
expect(billionRows?.yogaNode).toBeDefined()
expect(clippedHugeFill?.yogaNode).toBeDefined()
expect(nestedTextWrapper?.yogaNode).toBeDefined()
const nestedText = nestedTextWrapper!.childNodes.find(child => child.nodeName === 'ink-text') as
| DOMElement
| undefined
const nestedTextChild = nestedText?.childNodes[0]
expect(nestedTextChild).toBeDefined()
vi.spyOn(nanTop!.yogaNode!, 'getComputedTop').mockReturnValue(Number.NaN)
vi.spyOn(positiveInfinity!.yogaNode!, 'getComputedHeight').mockReturnValue(Number.POSITIVE_INFINITY)
vi.spyOn(negativeInfinity!.yogaNode!, 'getComputedHeight').mockReturnValue(Number.NEGATIVE_INFINITY)
vi.spyOn(billionRows!.yogaNode!, 'getComputedHeight').mockReturnValue(1_000_000_000)
vi.spyOn(clippedHugeFill!.yogaNode!, 'getComputedHeight').mockReturnValue(100_000_000)
vi.spyOn(clippedHugeFill!.yogaNode!, 'getComputedWidth').mockReturnValue(100_000_000)
let nestedOffsetX = 0
let nestedOffsetY = 0
nestedTextChild!.yogaNode = {
getComputedLeft: () => nestedOffsetX,
getComputedTop: () => nestedOffsetY
} as DOMElement['yogaNode']
output = ''
largestWrite = 0
largestWriteInput = 0
const corruptNestedOffsets = [
[Number.NaN, 0],
[Number.POSITIVE_INFINITY, 0],
[Number.NEGATIVE_INFINITY, 0],
[-1, 0],
[0.5, 0],
[100_000_000, 0],
[0, Number.NaN],
[0, Number.POSITIVE_INFINITY],
[0, Number.NEGATIVE_INFINITY],
[0, -1],
[0, 0.5],
[0, 100_000_000]
] as const
for (const [index, [offsetX, offsetY]] of corruptNestedOffsets.entries()) {
nestedOffsetX = offsetX
nestedOffsetY = offsetY
expect(() => {
instance.rerender(React.createElement(CorruptGeometryHarness, { expose, tick: index + 1 }))
}).not.toThrow()
await delay(5)
}
await delay(40)
expect(output).toContain(`adjacent-valid-${corruptNestedOffsets.length}`)
expect(largestWriteInput).toBeLessThan(10_000)
expect(largestWrite).toBeLessThan(10_000)
expect(output.length).toBeLessThan(50_000)
} finally {
vi.restoreAllMocks()
instance.unmount()
instance.cleanup()
}
})
it('clips corrupt dirty-child DECSTBM repairs before allocating or recursing', async () => {
const expose = {
current: {
adjacent: null,
dirtyChild: null,
overlay: null,
scroll: null,
scrollBox: null
} as FastPathRepairExpose
}
const streams = makeStreams()
let output = ''
const instance = renderSourceSync(
React.createElement(FastPathRepairHarness, { expose, includeOverlay: false, tick: 0 }),
{
patchConsole: false,
stderr: streams.stderr as NodeJS.WriteStream,
stdin: streams.stdin as NodeJS.ReadStream,
stdout: streams.stdout as NodeJS.WriteStream
}
)
try {
await delay(20)
const dirtyChild = expose.current!.dirtyChild!
expect(dirtyChild, streams.stderr.read()?.toString()).not.toBeNull()
expect(dirtyChild.yogaNode).toBeDefined()
vi.spyOn(dirtyChild.yogaNode!, 'getComputedTop').mockReturnValue(-99_999_995)
vi.spyOn(dirtyChild.yogaNode!, 'getComputedWidth').mockReturnValue(100_000_000)
vi.spyOn(dirtyChild.yogaNode!, 'getComputedHeight').mockReturnValue(100_000_000)
instance.rerender(React.createElement(FastPathRepairHarness, { expose, includeOverlay: false, tick: 1 }))
await delay(20)
streams.stdout.removeAllListeners('data')
streams.stdout.on('data', chunk => {
output += chunk.toString()
})
const observed = guardFastPathRepairAllocations(80, 20)
const fastPathsBefore = sourceScrollFastPathStats.taken
const capturedBefore = sourceScrollFastPathStats.captured
expect(() => expose.current!.scroll!.scrollTo(1)).not.toThrow()
expect(() =>
instance.rerender(React.createElement(FastPathRepairHarness, { expose, includeOverlay: false, tick: 2 }))
).not.toThrow()
await delay(40)
expect(sourceScrollFastPathStats.captured, JSON.stringify(sourceScrollFastPathStats)).toBeGreaterThan(
capturedBefore
)
expect(sourceScrollFastPathStats.taken, JSON.stringify(sourceScrollFastPathStats)).toBeGreaterThan(
fastPathsBefore
)
expect(observed.repairWhitespaceWrites).toBeGreaterThan(0)
expect(observed.largestRepeat).toBeGreaterThan(0)
expect(observed.largestArrayRows).toBeGreaterThan(0)
output = ''
expect(() =>
instance.rerender(
React.createElement(FastPathRepairHarness, {
dirtyTick: 2,
expose,
includeOverlay: false,
tick: 3
})
)
).not.toThrow()
await delay(40)
expect(observed.largestRepeat).toBeLessThanOrEqual(80)
expect(observed.largestArrayRows).toBeLessThanOrEqual(20)
expect(observed.largestBlitWidth).toBeLessThanOrEqual(80)
expect(observed.largestBlitHeight).toBeLessThanOrEqual(20)
expect(observed.largestClearWidth).toBeLessThanOrEqual(80)
expect(observed.largestClearHeight).toBeLessThanOrEqual(20)
expect(observed.largestWrite).toBeLessThanOrEqual(1_620)
expect(expose.current!.scroll!.getScrollTop()).toBe(1)
expect(expose.current!.scroll!.getViewportHeight()).toBeGreaterThan(0)
expect(output).toContain('adjacent-fast-path-3')
expect(streams.stderr.read()?.toString() ?? '').toBe('')
} finally {
vi.restoreAllMocks()
instance.unmount()
instance.cleanup()
}
})
it('clips corrupt absolute-overlay DECSTBM repairs before allocating or recursing', async () => {
const expose = {
current: {
adjacent: null,
dirtyChild: null,
overlay: null,
scroll: null,
scrollBox: null
} as FastPathRepairExpose
}
const streams = makeStreams()
let output = ''
const instance = renderSourceSync(React.createElement(FastPathRepairHarness, { expose, tick: 0 }), {
patchConsole: false,
stderr: streams.stderr as NodeJS.WriteStream,
stdin: streams.stdin as NodeJS.ReadStream,
stdout: streams.stdout as NodeJS.WriteStream
})
try {
await delay(20)
const overlay = expose.current!.overlay!
const scrollBox = expose.current!.scrollBox!
expect(overlay, streams.stderr.read()?.toString()).not.toBeNull()
expect(overlay.yogaNode).toBeDefined()
expect(scrollBox.yogaNode).toBeDefined()
vi.spyOn(scrollBox.yogaNode!, 'getComputedWidth').mockReturnValue(100_000_000)
vi.spyOn(overlay.yogaNode!, 'getComputedWidth').mockReturnValue(100_000_000)
vi.spyOn(overlay.yogaNode!, 'getComputedHeight').mockReturnValue(100_000_000)
instance.rerender(React.createElement(FastPathRepairHarness, { expose, tick: 1 }))
await delay(20)
instance.rerender(React.createElement(FastPathRepairHarness, { expose, tick: 1 }))
await delay(20)
streams.stdout.removeAllListeners('data')
streams.stdout.on('data', chunk => {
output += chunk.toString()
})
const observed = guardFastPathRepairAllocations(80, 20)
const fastPathsBefore = sourceScrollFastPathStats.taken
const capturedBefore = sourceScrollFastPathStats.captured
expect(() => expose.current!.scroll!.scrollTo(1)).not.toThrow()
expect(expose.current!.scroll!.getScrollTop()).toBe(1)
await delay(40)
expect(sourceScrollFastPathStats.captured, JSON.stringify(sourceScrollFastPathStats)).toBeGreaterThan(
capturedBefore
)
expect(sourceScrollFastPathStats.taken, JSON.stringify(sourceScrollFastPathStats)).toBeGreaterThan(
fastPathsBefore
)
expect(observed.repairWhitespaceWrites).toBeGreaterThan(0)
expect(observed.largestRepeat).toBeGreaterThan(0)
expect(observed.largestArrayRows).toBeGreaterThan(0)
output = ''
expect(() =>
instance.rerender(React.createElement(FastPathRepairHarness, { dirtyTick: 1, expose, tick: 2 }))
).not.toThrow()
await delay(40)
expect(observed.largestRepeat).toBeLessThanOrEqual(80)
expect(observed.largestArrayRows).toBeLessThanOrEqual(20)
expect(observed.largestBlitWidth).toBeLessThanOrEqual(80)
expect(observed.largestBlitHeight).toBeLessThanOrEqual(20)
expect(observed.largestClearWidth).toBeLessThanOrEqual(80)
expect(observed.largestClearHeight).toBeLessThanOrEqual(20)
expect(observed.largestWrite).toBeLessThanOrEqual(1_620)
expect(output.length).toBeLessThan(2_000)
expect(expose.current!.scroll!.getViewportHeight()).toBeGreaterThan(0)
expect(output).toContain('adjacent-fast-path-2')
expect(streams.stderr.read()?.toString() ?? '').toBe('')
} finally {
vi.restoreAllMocks()
instance.unmount()
instance.cleanup()
}
})
})

View File

@@ -36,4 +36,24 @@ describe('ensureVirtualItemHeight', () => {
expect(ensureVirtualItemHeight(heights, 'd', 0, 0, estimateHeight)).toBe(1)
expect(heights.get('d')).toBe(1)
})
it.each([Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY, -4, 1_000_000_000])(
'quarantines invalid cached height %s and reseeds it',
cached => {
const heights = new Map([['bad', cached]])
expect(ensureVirtualItemHeight(heights, 'bad', 0, 4, () => 7)).toBe(7)
expect(heights.get('bad')).toBe(7)
}
)
it.each([Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY, -4, 1_000_000_000])(
'falls back when the estimator returns invalid height %s',
estimate => {
const heights = new Map<string, number>()
expect(ensureVirtualItemHeight(heights, 'bad', 0, 4, () => estimate)).toBe(4)
expect(heights.get('bad')).toBe(4)
}
)
})

View File

@@ -2,14 +2,16 @@ import { PassThrough } from 'stream'
import { Box, renderSync, ScrollBox, type ScrollBoxHandle, Text } from '@hermes/ink'
import React, { useLayoutEffect, useRef } from 'react'
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { useVirtualHistory, virtualHistorySnapshotKey } from '../hooks/useVirtualHistory.js'
import { MAX_HISTORY } from '../config/limits.js'
import { pruneVirtualHeightCache, useVirtualHistory, virtualHistorySnapshotKey } from '../hooks/useVirtualHistory.js'
interface Item {
height: number
heightAfterResize?: number
key: string
text?: string
}
interface Exposed {
@@ -61,12 +63,16 @@ function Harness({
columns = 80,
expose,
height = 10,
generation = 0,
initialHeights,
items,
maxMounted = 16
}: {
columns?: number
expose: React.MutableRefObject<Exposed | null>
height?: number
generation?: number
initialHeights?: ReadonlyMap<string, number>
items: readonly Item[]
maxMounted?: number
}) {
@@ -75,6 +81,8 @@ function Harness({
const virtualHistory = useVirtualHistory(scrollRef, items, columns, {
coldStartCount: 16,
estimateHeight: index => itemHeightForColumns(items[index], columns),
generation,
initialHeights,
maxMounted,
overscan: 2
})
@@ -98,7 +106,7 @@ function Harness({
key: item.key,
ref: virtualHistory.measureRef(item.key)
},
React.createElement(Text, null, item.key)
React.createElement(Text, null, item.text ?? item.key)
)
),
virtualHistory.bottomSpacer > 0 ? React.createElement(Box, { height: virtualHistory.bottomSpacer }) : null
@@ -107,6 +115,17 @@ function Harness({
}
describe('useVirtualHistory offset cache reuse', () => {
it('prunes stable-session external height caches to active history keys', () => {
const cache = new Map([
['outgoing', 9],
['active', 3]
])
pruneVirtualHeightCache(cache, [{ key: 'active' }])
expect([...cache]).toEqual([['active', 3]])
})
it('includes viewport height in the external-store snapshot key', () => {
const base = {
getPendingDelta: () => 0,
@@ -247,9 +266,358 @@ describe('useVirtualHistory offset cache reuse', () => {
}
})
it('adjusts the committed viewport without consuming pending scroll intent', async () => {
const items = Array.from({ length: 20 }, (_, index) => ({ height: 2, key: `item-${index}` }))
const expose = { current: null as Exposed | null }
const streams = makeStreams()
const instance = renderSync(React.createElement(Harness, { expose, items }), {
patchConsole: false,
stderr: streams.stderr as NodeJS.WriteStream,
stdin: streams.stdin as NodeJS.ReadStream,
stdout: streams.stdout as NodeJS.WriteStream
})
try {
await delay(20)
const scroll = expose.current!.scroll!
scroll.scrollTo(3)
scroll.scrollBy(2)
scroll.adjustScrollTop(4)
expect(scroll.getScrollTop()).toBe(7)
expect(scroll.getPendingDelta()).toBe(2)
expect(scroll.isSticky()).toBe(false)
} finally {
instance.unmount()
instance.cleanup()
}
})
it('keeps the tail clamp open while a manual non-sticky tail grows', async () => {
const before = Array.from({ length: 20 }, (_, index) => ({ height: 2, key: `item-${index}` }))
const after = before.map((item, index) => (index === before.length - 1 ? { ...item, height: 8 } : item))
const expose = { current: null as Exposed | null }
const streams = makeStreams()
const initialHeights = new Map(before.map(item => [item.key, item.height]))
const instance = renderSync(React.createElement(Harness, { expose, initialHeights, items: before }), {
patchConsole: false,
stderr: streams.stderr as NodeJS.WriteStream,
stdin: streams.stdin as NodeJS.ReadStream,
stdout: streams.stdout as NodeJS.WriteStream
})
try {
await delay(20)
const scroll = expose.current!.scroll!
const setClampBounds = vi.spyOn(scroll, 'setClampBounds')
scroll.scrollTo(28)
await delay(20)
instance.rerender(React.createElement(Harness, { expose, initialHeights, items: after }))
await delay(60)
expect(scroll.isSticky()).toBe(false)
expect(setClampBounds.mock.calls.some(([, max]) => max === Number.POSITIVE_INFINITY)).toBe(true)
scroll.scrollTo(36)
await delay(20)
expect(scroll.getScrollTop()).toBe(36)
} finally {
instance.unmount()
instance.cleanup()
}
})
it('quarantines invalid measured heights before cache and compensation', async () => {
const items = Array.from({ length: 20 }, (_, index) => ({ height: 2, key: `item-${index}` }))
const expose = { current: null as Exposed | null }
const streams = makeStreams()
const initialHeights = new Map(items.map(item => [item.key, item.height]))
const instance = renderSync(React.createElement(Harness, { expose, initialHeights, items }), {
patchConsole: false,
stderr: streams.stderr as NodeJS.WriteStream,
stdin: streams.stdin as NodeJS.ReadStream,
stdout: streams.stdout as NodeJS.WriteStream
})
try {
await delay(20)
const scroll = expose.current!.scroll!
scroll.scrollTo(5)
await delay(20)
const adjustScrollTop = vi.spyOn(scroll, 'adjustScrollTop')
const ref = expose.current!.virtualHistory.measureRef('item-1')
for (const height of [Number.NaN, Number.POSITIVE_INFINITY, -1, 1_000_000_000]) {
ref({ yogaNode: { getComputedHeight: () => height } })
ref(null)
}
expect(adjustScrollTop).not.toHaveBeenCalled()
expect(expose.current!.virtualHistory.offsets[items.length]).toBe(40)
expect(Number.isFinite(scroll.getScrollTop())).toBe(true)
} finally {
instance.unmount()
instance.cleanup()
}
})
it('preserves the visual anchor when a measured row above the viewport changes height', async () => {
const before = Array.from({ length: 20 }, (_, index) => ({ height: 2, key: `item-${index}` }))
const after = before.map((item, index) => (index === 0 ? { ...item, height: 5 } : item))
const expose = { current: null as Exposed | null }
const streams = makeStreams()
const initialHeights = new Map(before.map(item => [item.key, item.height]))
const instance = renderSync(React.createElement(Harness, { expose, initialHeights, items: before }), {
patchConsole: false,
stderr: streams.stderr as NodeJS.WriteStream,
stdin: streams.stdin as NodeJS.ReadStream,
stdout: streams.stdout as NodeJS.WriteStream
})
try {
await delay(20)
expose.current!.scroll!.scrollTo(3)
await delay(20)
instance.rerender(React.createElement(Harness, { expose, initialHeights, items: after }))
await delay(40)
expect(expose.current!.scroll!.getScrollTop()).toBe(6)
} finally {
instance.unmount()
instance.cleanup()
}
})
it('keeps a compensated near-tail viewport manual', async () => {
const before = Array.from({ length: 20 }, (_, index) => ({ height: 2, key: `item-${index}` }))
const after = before.map((item, index) => (index === 13 ? { ...item, height: 5 } : item))
const expose = { current: null as Exposed | null }
const streams = makeStreams()
const initialHeights = new Map(before.map(item => [item.key, item.height]))
const instance = renderSync(React.createElement(Harness, { expose, initialHeights, items: before }), {
patchConsole: false,
stderr: streams.stderr as NodeJS.WriteStream,
stdin: streams.stdin as NodeJS.ReadStream,
stdout: streams.stdout as NodeJS.WriteStream
})
try {
await delay(20)
const scroll = expose.current!.scroll!
scroll.scrollTo(29)
await delay(20)
instance.rerender(React.createElement(Harness, { expose, initialHeights, items: after }))
expect(scroll.getScrollTop()).toBe(32)
expect(scroll.isSticky()).toBe(false)
} finally {
instance.unmount()
instance.cleanup()
}
})
it('ignores stale unmount measurement from the previous width layout', async () => {
const items = Array.from({ length: 20 }, (_, index) => ({
height: 4,
heightAfterResize: index === 0 ? 5 : 2,
key: `item-${index}`
}))
const expose = { current: null as Exposed | null }
const streams = makeStreams()
const initialHeights = new Map(items.map(item => [item.key, item.height]))
const instance = renderSync(React.createElement(Harness, { columns: 40, expose, initialHeights, items }), {
patchConsole: false,
stderr: streams.stderr as NodeJS.WriteStream,
stdin: streams.stdin as NodeJS.ReadStream,
stdout: streams.stdout as NodeJS.WriteStream
})
try {
await delay(20)
const scroll = expose.current!.scroll!
scroll.scrollTo(0)
await delay(20)
scroll.scrollTo(5)
const adjustScrollTop = vi.spyOn(scroll, 'adjustScrollTop')
instance.rerender(React.createElement(Harness, { columns: 80, expose, initialHeights, items }))
await delay(40)
expect(adjustScrollTop).not.toHaveBeenCalled()
expect(scroll.getScrollTop()).toBe(5)
expect(scroll.isSticky()).toBe(false)
expect(expose.current!.virtualHistory.start).toBeGreaterThan(0)
expect(expose.current!.virtualHistory.offsets[1]).toBe(2)
expect(expose.current!.virtualHistory.offsets[items.length]).toBe(40)
} finally {
instance.unmount()
instance.cleanup()
}
})
it('does not let outgoing transcript refs compensate a new layout generation', async () => {
const outgoing = Array.from({ length: 20 }, (_, index) => ({ height: 2, key: `old-${index}` }))
const incoming = Array.from({ length: 20 }, (_, index) => ({ height: 2, key: `new-${index}` }))
const expose = { current: null as Exposed | null }
const streams = makeStreams()
const initialHeights = new Map(outgoing.map(item => [item.key, item.height]))
const instance = renderSync(React.createElement(Harness, { expose, initialHeights, items: outgoing }), {
patchConsole: false,
stderr: streams.stderr as NodeJS.WriteStream,
stdin: streams.stdin as NodeJS.ReadStream,
stdout: streams.stdout as NodeJS.WriteStream
})
try {
await delay(20)
const scroll = expose.current!.scroll!
scroll.scrollTo(5)
await delay(20)
const adjustScrollTop = vi.spyOn(scroll, 'adjustScrollTop')
const replacementCache = new Map<string, number>([
...outgoing.map(item => [item.key, item.key === 'old-1' ? 1 : item.height] as const),
...incoming.map(item => [item.key, item.height] as const)
])
instance.rerender(
React.createElement(Harness, {
expose,
generation: 1,
initialHeights: replacementCache,
items: incoming
})
)
await delay(40)
expect(adjustScrollTop).not.toHaveBeenCalled()
expect(scroll.getScrollTop()).toBe(5)
expect(expose.current!.virtualHistory.offsets[incoming.length]).toBe(40)
} finally {
instance.unmount()
instance.cleanup()
}
})
it('corrects and compensates a same-layout row measured at unmount', async () => {
const items = Array.from({ length: 20 }, (_, index) => ({ height: 2, key: `item-${index}` }))
const expose = { current: null as Exposed | null }
const streams = makeStreams()
const initialHeights = new Map(items.map(item => [item.key, item.height]))
const instance = renderSync(React.createElement(Harness, { expose, initialHeights, items }), {
patchConsole: false,
stderr: streams.stderr as NodeJS.WriteStream,
stdin: streams.stdin as NodeJS.ReadStream,
stdout: streams.stdout as NodeJS.WriteStream
})
try {
await delay(20)
const scroll = expose.current!.scroll!
scroll.scrollTo(0)
await delay(20)
scroll.scrollTo(5)
const adjustScrollTop = vi.spyOn(scroll, 'adjustScrollTop')
const staleHeights = new Map(initialHeights)
staleHeights.set(items[0]!.key, 1)
instance.rerender(React.createElement(Harness, { expose, initialHeights: staleHeights, items }))
await delay(40)
expect(adjustScrollTop).toHaveBeenCalledOnce()
expect(adjustScrollTop).toHaveBeenCalledWith(1)
expect(scroll.getScrollTop()).toBe(6)
expect(scroll.isSticky()).toBe(false)
expect(expose.current!.virtualHistory.start).toBeGreaterThan(0)
expect(expose.current!.virtualHistory.offsets[1]).toBe(2)
} finally {
instance.unmount()
instance.cleanup()
}
})
it('does not compensate for measured height changes in or below the viewport', async () => {
const before = Array.from({ length: 20 }, (_, index) => ({ height: 2, key: `item-${index}` }))
const visibleChanged = before.map((item, index) => (index === 1 ? { ...item, height: 5 } : item))
const belowChanged = visibleChanged.map((item, index) => (index === 8 ? { ...item, height: 5 } : item))
const expose = { current: null as Exposed | null }
const streams = makeStreams()
const initialHeights = new Map(before.map(item => [item.key, item.height]))
const instance = renderSync(React.createElement(Harness, { expose, initialHeights, items: before }), {
patchConsole: false,
stderr: streams.stderr as NodeJS.WriteStream,
stdin: streams.stdin as NodeJS.ReadStream,
stdout: streams.stdout as NodeJS.WriteStream
})
try {
await delay(20)
expose.current!.scroll!.scrollTo(3)
await delay(20)
instance.rerender(React.createElement(Harness, { expose, initialHeights, items: visibleChanged }))
await delay(40)
expect(expose.current!.scroll!.getScrollTop()).toBe(3)
instance.rerender(React.createElement(Harness, { expose, initialHeights, items: belowChanged }))
await delay(40)
expect(expose.current!.scroll!.getScrollTop()).toBe(3)
} finally {
instance.unmount()
instance.cleanup()
}
})
it('does not compensate measured heights while sticky at the live tail', async () => {
const before = Array.from({ length: 20 }, (_, index) => ({ height: 2, key: `item-${index}` }))
const after = before.map((item, index) => (index === 14 ? { ...item, height: 5 } : item))
const expose = { current: null as Exposed | null }
const streams = makeStreams()
const initialHeights = new Map(before.map(item => [item.key, item.height]))
const instance = renderSync(React.createElement(Harness, { expose, initialHeights, items: before }), {
patchConsole: false,
stderr: streams.stderr as NodeJS.WriteStream,
stdin: streams.stdin as NodeJS.ReadStream,
stdout: streams.stdout as NodeJS.WriteStream
})
try {
await delay(20)
const adjustScrollTop = vi.spyOn(expose.current!.scroll!, 'adjustScrollTop')
instance.rerender(React.createElement(Harness, { expose, initialHeights, items: after }))
await delay(40)
expect(adjustScrollTop).not.toHaveBeenCalled()
expect(expose.current!.scroll!.isSticky()).toBe(true)
} finally {
instance.unmount()
instance.cleanup()
}
})
it('ignores stale reused offset-array entries after the item count shrinks', async () => {
const beforeShrink = Array.from({ length: 1400 }, (_, index) => ({ height: 1, key: `old${index}` }))
const afterShrink = Array.from({ length: 800 }, (_, index) => ({ height: 7, key: `new${index}` }))
const afterShrink = Array.from({ length: MAX_HISTORY }, (_, index) => ({ height: 7, key: `new${index}` }))
const expose = { current: null as Exposed | null }
const streams = makeStreams()

View File

@@ -67,5 +67,8 @@ export function scrollWithSelectionBy(delta: number, { scrollRef, selection }: S
shift(-actual, top, bottom)
}
s.scrollBy(actual)
// The target is already accepted and clamped here. Commit it directly so
// wheel/page input does not enter ScrollBox's multi-frame pending-delta
// drain and produce visible stair steps at virtual row boundaries.
s.scrollTo(cur + actual)
}

View File

@@ -12,7 +12,7 @@ import { useStore } from '@nanostores/react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { DASHBOARD_TUI_MODE, STARTUP_RESUME_ID } from '../config/env.js'
import { MAX_HISTORY, WHEEL_SCROLL_STEP } from '../config/limits.js'
import { WHEEL_SCROLL_STEP } from '../config/limits.js'
import { RESIZE_COALESCE_MS } from '../config/timing.js'
import { hasLeadGap, prevRenderedMsg } from '../domain/blockLayout.js'
import { SECTION_NAMES, sectionMode } from '../domain/details.js'
@@ -28,9 +28,9 @@ import type {
TerminalResizeResponse
} from '../gatewayTypes.js'
import { useGitBranch } from '../hooks/useGitBranch.js'
import { useVirtualHistory } from '../hooks/useVirtualHistory.js'
import { pruneVirtualHeightCache, useVirtualHistory } from '../hooks/useVirtualHistory.js'
import { composerPromptWidth } from '../lib/inputMetrics.js'
import { appendTranscriptMessage } from '../lib/messages.js'
import { appendTranscriptMessage, capTranscriptHistory } from '../lib/messages.js'
import { DEFAULT_VOICE_RECORD_KEY, isMac, type ParsedVoiceRecordKey } from '../lib/platform.js'
import { createResizeCoalescer } from '../lib/resizeCoalescer.js'
import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
@@ -63,14 +63,6 @@ const BRACKET_PASTE_ON = '\x1b[?2004h'
const BRACKET_PASTE_OFF = '\x1b[?2004l'
const MAX_HEIGHT_CACHE_BUCKETS = 12
const capHistory = (items: Msg[]): Msg[] => {
if (items.length <= MAX_HISTORY) {
return items
}
return items[0]?.kind === 'intro' ? [items[0]!, ...items.slice(-(MAX_HISTORY - 1))] : items.slice(-MAX_HISTORY)
}
const statusColorOf = (status: string, t: { error: string; muted: string; ok: string; warn: string }) => {
if (status === 'ready') {
return t.ok
@@ -183,7 +175,17 @@ export function useMainApp(gw: GatewayClient) {
}
}, [stdout])
const [historyItems, setHistoryItems] = useState<Msg[]>(() => [{ kind: 'intro', role: 'system', text: '' }])
const [historyItems, setHistoryItemsState] = useState<Msg[]>(() => [{ kind: 'intro', role: 'system', text: '' }])
const [historyGeneration, setHistoryGeneration] = useState(0)
const setHistoryItems = useCallback<StateSetter<Msg[]>>(value => {
if (typeof value !== 'function') {
setHistoryGeneration(generation => generation + 1)
}
setHistoryItemsState(previous => capTranscriptHistory(typeof value === 'function' ? value(previous) : value))
}, [])
const [lastUserMsg, setLastUserMsg] = useState('')
const [stickyPrompt, setStickyPrompt] = useState('')
const [catalog, setCatalog] = useState<null | SlashCatalog>(null)
@@ -355,20 +357,20 @@ export function useMainApp(gw: GatewayClient) {
const userPromptWidth = composerPromptWidth(ui.theme.brand.prompt)
const heightCacheKey = `${ui.sid ?? 'draft'}:${cols}:${userPromptWidth}:${ui.compact ? '1' : '0'}:${detailsLayoutKey}`
const heightCache = useMemo(() => {
let cache = heightCachesRef.current.get(heightCacheKey)
// Build a render-local snapshot. Registering/pruning the shared cache is a
// post-commit transition below, so an abandoned concurrent render cannot
// delete heights still owned by the committed transcript generation.
const activeHeightCache = useMemo(() => new Map(heightCachesRef.current.get(heightCacheKey)), [heightCacheKey])
if (!cache) {
cache = new Map()
heightCachesRef.current.set(heightCacheKey, cache)
useEffect(() => {
pruneVirtualHeightCache(activeHeightCache, virtualRows)
heightCachesRef.current.delete(heightCacheKey)
heightCachesRef.current.set(heightCacheKey, activeHeightCache)
if (heightCachesRef.current.size > MAX_HEIGHT_CACHE_BUCKETS) {
heightCachesRef.current.delete(heightCachesRef.current.keys().next().value!)
}
while (heightCachesRef.current.size > MAX_HEIGHT_CACHE_BUCKETS) {
heightCachesRef.current.delete(heightCachesRef.current.keys().next().value!)
}
return cache
}, [heightCacheKey])
}, [activeHeightCache, heightCacheKey, historyGeneration, virtualRows])
// Index of the first user-role message — separator-rendering in
// appLayout.tsx skips this row, so the height estimator must skip it
@@ -414,16 +416,17 @@ export function useMainApp(gw: GatewayClient) {
const h = heights.get(row.key)
if (h) {
heightCache.set(row.key, h)
activeHeightCache.set(row.key, h)
}
}
},
[heightCache, virtualRows]
[activeHeightCache, virtualRows]
)
const virtualHistory = useVirtualHistory(scrollRef, virtualRows, cols, {
estimateHeight: estimateRowHeight,
initialHeights: heightCache,
generation: historyGeneration,
initialHeights: activeHeightCache,
liveTailActive: turnLiveTailActive,
onHeightsChange: syncHeightCache
})
@@ -434,8 +437,8 @@ export function useMainApp(gw: GatewayClient) {
)
const appendMessage = useCallback(
(msg: Msg) => setHistoryItems(prev => capHistory(appendTranscriptMessage(prev, msg))),
[]
(msg: Msg) => setHistoryItems(prev => appendTranscriptMessage(prev, msg)),
[setHistoryItems]
)
const sys = useCallback((text: string) => appendMessage({ role: 'system', text }), [appendMessage])
@@ -801,6 +804,7 @@ export function useMainApp(gw: GatewayClient) {
session.newSession,
session.resetSession,
session.resumeById,
setHistoryItems,
setVoiceEnabled,
setVoiceProcessing,
setVoiceRecording,
@@ -912,6 +916,7 @@ export function useMainApp(gw: GatewayClient) {
selection,
send,
session,
setHistoryItems,
sys
]
)

View File

@@ -48,17 +48,39 @@ const FREEZE_RENDERS = 2
// from 25 → 12: each new item adds ~100 fibers / Yoga nodes, and a
// 25-item commit was the dominant contributor to the 100ms+ p99 frames.
const SLIDE_STEP = 12
const MAX_VIRTUAL_ITEM_HEIGHT = 100_000
const MAX_VIRTUAL_GEOMETRY = 1_000_000_000
const NOOP = () => {}
const validVirtualItemHeight = (value: number): boolean =>
Number.isFinite(value) && value > 0 && value <= MAX_VIRTUAL_ITEM_HEIGHT
const safeUnsignedGeometry = (value: number, fallback = 0): number =>
Number.isFinite(value) && value >= 0 && value <= MAX_VIRTUAL_GEOMETRY ? value : fallback
const safeSignedGeometry = (value: number, fallback = 0): number =>
Number.isFinite(value) && Math.abs(value) <= MAX_VIRTUAL_GEOMETRY ? value : fallback
export const pruneVirtualHeightCache = (cache: Map<string, number>, items: readonly { key: string }[]): void => {
const active = new Set(items.map(item => item.key))
for (const key of cache.keys()) {
if (!active.has(key) || !validVirtualItemHeight(cache.get(key)!)) {
cache.delete(key)
}
}
}
export const virtualHistorySnapshotKey = (s?: ScrollBoxHandle | null): string => {
if (!s) {
return 'none'
}
const target = s.getScrollTop() + s.getPendingDelta()
const target = safeUnsignedGeometry(safeUnsignedGeometry(s.getScrollTop()) + safeSignedGeometry(s.getPendingDelta()))
const bin = Math.floor(target / QUANTUM)
const viewportHeight = Math.max(0, s.getViewportHeight())
const viewportHeight = safeUnsignedGeometry(s.getViewportHeight())
return `${s.isSticky() ? ~bin : bin}:${viewportHeight}`
}
@@ -97,11 +119,17 @@ export const ensureVirtualItemHeight = (
) => {
const cached = heights.get(key)
if (cached !== undefined) {
return Math.max(1, Math.floor(cached))
if (cached !== undefined && validVirtualItemHeight(cached)) {
return Math.floor(cached)
}
const seeded = Math.max(1, Math.floor(estimateHeight?.(index, key) ?? estimate))
if (cached !== undefined) {
heights.delete(key)
}
const fallback = validVirtualItemHeight(estimate) ? Math.floor(estimate) : estimate === 0 ? 1 : ESTIMATE
const candidate = estimateHeight?.(index, key) ?? fallback
const seeded = validVirtualItemHeight(candidate) ? Math.floor(candidate) : candidate === 0 ? 1 : fallback
heights.set(key, seeded)
return seeded
@@ -114,6 +142,7 @@ export function useVirtualHistory(
{
estimate = ESTIMATE,
estimateHeight,
generation = 0,
initialHeights,
liveTailActive = false,
onHeightsChange,
@@ -126,15 +155,16 @@ export function useVirtualHistory(
const heights = useRef(new Map(initialHeights))
const initialHeightsRef = useRef(initialHeights)
const refs = useRef(new Map<string, (el: unknown) => void>())
const measuredBottoms = useRef(new Map<string, number>())
const unmountViewport = useRef<{ sticky: boolean; top: number } | null>(null)
const onHeightsChangeRef = useRef(onHeightsChange)
// Bump whenever heightCache mutates so offsets rebuild on next read.
// Ref (not state) — checked during render phase, zero extra commits.
const offsetVersion = useRef(0)
// Cached offsets: reused Float64Array keyed on (itemCount, version) so we
// only rebuild when something actually changed. Previous approach allocated
// a fresh Array(n+1) every render — at n=10k that's ~80KB/render of GC
// pressure during streaming.
// Cached offsets: reused Float64Array keyed on (itemCount, version). Clean
// renders reuse it, but a point-height invalidation still rebuilds the full
// prefix array; this is allocation reuse, not incremental prefix indexing.
const offsetsCache = useRef<{ arr: Float64Array; n: number; version: number }>({
arr: new Float64Array(0),
n: -1,
@@ -158,9 +188,25 @@ export function useVirtualHistory(
const skipMeasurement = useRef(false)
const prevRange = useRef<null | readonly [number, number]>(null)
const freezeRenders = useRef(0)
const generationRef = useRef<number | string>(generation)
onHeightsChangeRef.current = onHeightsChange
if (generationRef.current !== generation) {
generationRef.current = generation
nodes.current.clear()
refs.current.clear()
measuredBottoms.current.clear()
unmountViewport.current = null
heights.current = new Map(initialHeights)
initialHeightsRef.current = initialHeights
prevRange.current = null
freezeRenders.current = 0
skipMeasurement.current = false
lastScrollTopRef.current = 0
offsetVersion.current++
}
if (initialHeightsRef.current !== initialHeights) {
initialHeightsRef.current = initialHeights
heights.current = new Map(initialHeights)
@@ -173,7 +219,13 @@ export function useVirtualHistory(
prevColumns.current = columns
for (const [k, h] of heights.current) {
heights.current.set(k, Math.max(1, Math.round(h * ratio)))
const scaled = Math.round(h * ratio)
if (validVirtualItemHeight(scaled)) {
heights.current.set(k, scaled)
} else {
heights.current.delete(k)
}
}
offsetVersion.current++
@@ -209,6 +261,7 @@ export function useVirtualHistory(
heights.current.delete(k)
nodes.current.delete(k)
refs.current.delete(k)
measuredBottoms.current.delete(k)
dirty = true
}
}
@@ -238,10 +291,10 @@ export function useVirtualHistory(
const offsets = offsetsCache.current.arr
const total = offsets[n] ?? 0
const top = Math.max(0, scrollRef.current?.getScrollTop() ?? 0)
const pendingDelta = scrollRef.current?.getPendingDelta() ?? 0
const target = Math.max(0, top + pendingDelta)
const vp = Math.max(0, scrollRef.current?.getViewportHeight() ?? 0)
const top = safeUnsignedGeometry(scrollRef.current?.getScrollTop() ?? 0)
const pendingDelta = safeSignedGeometry(scrollRef.current?.getPendingDelta() ?? 0)
const target = safeUnsignedGeometry(top + pendingDelta)
const vp = safeUnsignedGeometry(scrollRef.current?.getViewportHeight() ?? 0)
const sticky = scrollRef.current?.isSticky() ?? true
const recentManual = Date.now() - (scrollRef.current?.getLastManualScrollAt() ?? 0) < 1200
@@ -417,44 +470,90 @@ export function useVirtualHistory(
}
}
const measureRef = useCallback((key: string) => {
let fn = refs.current.get(key)
const measureRef = useCallback(
(key: string) => {
let fn = refs.current.get(key)
if (!fn) {
fn = (el: unknown) => {
if (el) {
nodes.current.set(key, el)
if (!fn) {
const refGeneration = generationRef.current
return
fn = (el: unknown) => {
if (refGeneration !== generationRef.current) {
return
}
if (el) {
nodes.current.set(key, el)
return
}
// A width-change render has already scaled the cache, but outgoing
// refs still point at Yoga from the previous layout. The render-phase
// skip flag remains set through mutation refs, so ignore that stale
// measurement and let the post-resize layout pass own correction.
if (skipMeasurement.current) {
nodes.current.delete(key)
measuredBottoms.current.delete(key)
return
}
// Measure-at-unmount: the yogaNode is still valid here (reconciler
// calls ref(null) before removeChild → freeRecursive), so we grab
// the final height before WASM release. Without this, items
// scrolled out during fast pan keep a stale estimate in heightCache
// and offset math drifts until the next mount/remount cycle.
const existing = nodes.current.get(key) as MeasuredNode | undefined
const h = Math.ceil(existing?.yogaNode?.getComputedHeight?.() ?? 0)
const previousHeight = heights.current.get(key)
if (validVirtualItemHeight(h) && previousHeight !== h) {
const s = scrollRef.current
const measuredBottom = measuredBottoms.current.get(key)
// All null refs in this commit share the viewport boundary captured
// before the first adjustment. Otherwise an earlier compensation
// can make an intersecting sibling look wholly above later in the
// same unmount batch.
const viewport = (unmountViewport.current ??= {
sticky: s?.isSticky() ?? true,
top: safeUnsignedGeometry(s?.getScrollTop() ?? 0)
})
if (
s &&
previousHeight !== undefined &&
measuredBottom !== undefined &&
measuredBottom <= viewport.top &&
!viewport.sticky
) {
s.adjustScrollTop(h - previousHeight)
}
heights.current.set(key, h)
offsetVersion.current++
onHeightsChangeRef.current?.(heights.current)
}
nodes.current.delete(key)
measuredBottoms.current.delete(key)
}
// Measure-at-unmount: the yogaNode is still valid here (reconciler
// calls ref(null) before removeChild → freeRecursive), so we grab
// the final height before WASM release. Without this, items
// scrolled out during fast pan keep a stale estimate in heightCache
// and offset math drifts until the next mount/remount cycle.
const existing = nodes.current.get(key) as MeasuredNode | undefined
const h = Math.ceil(existing?.yogaNode?.getComputedHeight?.() ?? 0)
if (h > 0 && heights.current.get(key) !== h) {
heights.current.set(key, h)
offsetVersion.current++
onHeightsChangeRef.current?.(heights.current)
}
nodes.current.delete(key)
refs.current.set(key, fn)
}
refs.current.set(key, fn)
}
return fn
}, [])
return fn
},
[scrollRef]
)
useLayoutEffect(() => {
unmountViewport.current = null
const s = scrollRef.current
let dirty = false
let heightDirty = false
let anchorDelta = 0
// Give the renderer the mounted-row coverage for passive scroll clamping.
// Clamp MUST use the EFFECTIVE (deferred) range, not the immediate one.
@@ -466,20 +565,40 @@ export function useVirtualHistory(
if (s && shouldSetVirtualClamp({ itemCount: n, liveTailActive, sticky, viewportHeight: vp })) {
const effTopSpacer = offsets[effStart] ?? 0
const effBottom = offsets[effEnd] ?? total
// At effEnd=n there's no bottomSpacer — use Infinity so render-node-
// to-output's own Math.min(cur, maxScroll) governs. Using offsets[n]
// here would bake in heightCache (one render behind Yoga), and during
// streaming the tail item's cached height lags its real height —
// sticky-break would then clamp below the real max and push
// streaming text off-viewport.
const clampMin = effStart === 0 ? 0 : effTopSpacer
const clampMax = effEnd === n ? Infinity : Math.max(effTopSpacer, effBottom - vp)
// Preserve the intentional open tail: when the mounted range reaches
// the final row, Yoga may already know about growth that the measured
// height cache has not reconciled yet. A finite estimated clamp would
// trap a manual, non-sticky viewport above that newly grown tail.
const clampMax = effEnd === n ? Number.POSITIVE_INFINITY : Math.max(effTopSpacer, effBottom - vp)
s.setClampBounds(clampMin, clampMax)
if (
safeUnsignedGeometry(clampMin, -1) >= 0 &&
(clampMax === Number.POSITIVE_INFINITY || safeUnsignedGeometry(clampMax, -1) >= clampMin)
) {
s.setClampBounds(clampMin, clampMax)
} else {
s.setClampBounds(undefined, undefined)
}
} else {
s?.setClampBounds(undefined, undefined)
}
// Stable metadata for measure-at-unmount. Ref(null) runs before the next
// commit's layout effects, so an outgoing row sees the bottom recorded by
// the last committed mounted range. This stays O(mounted), not O(history).
for (let i = effStart; i < effEnd; i++) {
const k = items[i]?.key
if (k) {
const bottom = offsets[i + 1] ?? 0
if (safeUnsignedGeometry(bottom, -1) >= 0) {
measuredBottoms.current.set(k, bottom)
}
}
}
if (skipMeasurement.current) {
skipMeasurement.current = false
bumpMeasuredHeightVersion(n => n + 1)
@@ -492,8 +611,16 @@ export function useVirtualHistory(
}
const h = Math.ceil((nodes.current.get(k) as MeasuredNode | undefined)?.yogaNode?.getComputedHeight?.() ?? 0)
const previousHeight = heights.current.get(k)
if (validVirtualItemHeight(h) && previousHeight !== h) {
// Keep the same content at the same screen row when estimates above
// the committed viewport converge. Rows intersecting or below the
// viewport intentionally retain the current scrollTop.
if (previousHeight !== undefined && (offsets[i + 1] ?? 0) <= top) {
anchorDelta += h - previousHeight
}
if (h > 0 && heights.current.get(k) !== h) {
heights.current.set(k, h)
dirty = true
heightDirty = true
@@ -501,11 +628,18 @@ export function useVirtualHistory(
}
}
// Sticky/live-tail positioning is owned by ScrollBox's bottom-follow
// logic. Manual viewports need an additive adjustment that leaves any
// pending input intact; scrollTo would clear that intent and other state.
if (s && anchorDelta !== 0 && !s.isSticky()) {
s.adjustScrollTop(anchorDelta)
}
if (s) {
const next = {
sticky: s.isSticky(),
top: Math.max(0, s.getScrollTop() + s.getPendingDelta()),
vp: Math.max(0, s.getViewportHeight())
top: safeUnsignedGeometry(s.getScrollTop() + safeSignedGeometry(s.getPendingDelta())),
vp: safeUnsignedGeometry(s.getViewportHeight())
}
if (
@@ -526,7 +660,7 @@ export function useVirtualHistory(
if (heightDirty) {
bumpMeasuredHeightVersion(n => n + 1)
}
}, [effEnd, effStart, items, liveTailActive, measuredHeightVersion, n, offsets, scrollRef, sticky, total, vp])
}, [effEnd, effStart, items, liveTailActive, measuredHeightVersion, n, offsets, scrollRef, sticky, top, total, vp])
return {
bottomSpacer: Math.max(0, total - (offsets[effEnd] ?? total)),
@@ -546,6 +680,7 @@ interface VirtualHistoryOptions {
coldStartCount?: number
estimate?: number
estimateHeight?: (index: number, key: string) => number
generation?: number | string
initialHeights?: ReadonlyMap<string, number>
liveTailActive?: boolean
maxMounted?: number

View File

@@ -1,8 +1,17 @@
import { MAX_HISTORY } from '../config/limits.js'
import type { Msg, Role } from '../types.js'
import { appendToolShelfMessage } from './liveProgress.js'
export const appendTranscriptMessage = (prev: Msg[], msg: Msg): Msg[] => appendToolShelfMessage(prev, msg)
export const capTranscriptHistory = (items: Msg[]): Msg[] => {
if (items.length <= MAX_HISTORY) {
return items
}
return items[0]?.kind === 'intro' ? [items[0], ...items.slice(-(MAX_HISTORY - 1))] : items.slice(-MAX_HISTORY)
}
export const upsert = (prev: Msg[], role: Role, text: string): Msg[] =>
prev.at(-1)?.role === role ? [...prev.slice(0, -1), { role, text }] : [...prev, { role, text }]

View File

@@ -77,6 +77,7 @@ declare module '@hermes/ink' {
}
export type ScrollBoxHandle = {
readonly adjustScrollTop: (dy: number) => void
readonly scrollTo: (y: number) => void
readonly scrollBy: (dy: number) => void
readonly scrollToElement: (el: unknown, offset?: number) => void