perf(tui): instrument scroll fast-path decline reasons

Adds scrollFastPathStats counters to render-node-to-output.ts: captures
every time a ScrollBox's DECSTBM scroll hint is generated, records
whether the fast path took it (blit+shift from prevScreen) or declined,
and why. Exposed through hermes-ink's public exports and snapshotted on
every FrameEvent so the profiler harness can correlate decline reasons
with the actual patch/renderer cost per frame.

This is pure observation — no behaviour change. Preparing for the
virtual-history rewrite: the hypothesis was that our topSpacer/
bottomSpacer scheme disqualifies every scroll via heightDelta
mismatch, but the data shows the fast path is actually taken on most
scrolls (19/23 over a 6s PageUp hold through 1100 messages) — the
remaining steady-state renderer cost is Yoga tree traversal, not
the per-frame full redraw I initially suspected.

Declines that do happen correlate with React commits that changed the
mounted range mid-scroll (heightDelta=±3 to ±35). Those are the rarer
cases the virtualization rewrite still needs to address.

No test diffs — instrumentation-only.  Build verified: `tsc --noEmit`
plus the full `npm run build` compiler post-pass pass cleanly.
This commit is contained in:
Brooklyn Nicholson
2026-04-26 16:45:53 -05:00
parent 71eee26640
commit cd7a200e6c
4 changed files with 114 additions and 0 deletions

View File

@@ -101,6 +101,24 @@ declare module '@hermes/ink' {
export const TextInput: React.ComponentType<any>
export const stringWidth: (s: string) => number
export type ScrollFastPathStats = {
captured: number
taken: number
declined: {
noPrevScreen: number
heightDeltaMismatch: number
noHint: number
other: number
}
lastDeclineReason?: string
lastHeightDelta?: number
lastHintDelta?: number
lastScrollHeight?: number
lastPrevHeight?: number
}
export const scrollFastPathStats: ScrollFastPathStats
export function resetScrollFastPathStats(): void
export function render(node: React.ReactNode, options?: NodeJS.WriteStream | RenderOptions): Instance
export function useApp(): { readonly exit: (error?: Error) => void }