use-terminal-viewport.ts
1 import { useCallback, useContext, useLayoutEffect, useRef } from 'react' 2 import { TerminalSizeContext } from '../components/TerminalSizeContext.js' 3 import type { DOMElement } from '../dom.js' 4 5 type ViewportEntry = { 6 /** 7 * Whether the element is currently within the terminal viewport 8 */ 9 isVisible: boolean 10 } 11 12 /** 13 * Hook to detect if a component is within the terminal viewport. 14 * 15 * Returns a callback ref and a viewport entry object. 16 * Attach the ref to the component you want to track. 17 * 18 * The entry is updated during the layout phase (useLayoutEffect) so callers 19 * always read fresh values during render. Visibility changes do NOT trigger 20 * re-renders on their own — callers that re-render for other reasons (e.g. 21 * animation ticks, state changes) will pick up the latest value naturally. 22 * This avoids infinite update loops when combined with other layout effects 23 * that also call setState. 24 * 25 * @example 26 * const [ref, entry] = useTerminalViewport() 27 * return <Box ref={ref}><Animation enabled={entry.isVisible}>...</Animation></Box> 28 */ 29 export function useTerminalViewport(): [ 30 ref: (element: DOMElement | null) => void, 31 entry: ViewportEntry, 32 ] { 33 const terminalSize = useContext(TerminalSizeContext) 34 const elementRef = useRef<DOMElement | null>(null) 35 const entryRef = useRef<ViewportEntry>({ isVisible: true }) 36 37 const setElement = useCallback((el: DOMElement | null) => { 38 elementRef.current = el 39 }, []) 40 41 // Runs on every render because yoga layout values can change 42 // without React being aware. Only updates the ref — no setState 43 // to avoid cascading re-renders during the commit phase. 44 // Walks the DOM ancestor chain fresh each time to avoid holding stale 45 // references after yoga tree rebuilds. 46 useLayoutEffect(() => { 47 const element = elementRef.current 48 if (!element?.yogaNode || !terminalSize) { 49 return 50 } 51 52 const height = element.yogaNode.getComputedHeight() 53 const rows = terminalSize.rows 54 55 // Walk the DOM parent chain (not yoga.getParent()) so we can detect 56 // scroll containers and subtract their scrollTop. Yoga computes layout 57 // positions without scroll offset — scrollTop is applied at render time. 58 // Without this, an element inside a ScrollBox whose yoga position exceeds 59 // terminalRows would be considered offscreen even when scrolled into view 60 // (e.g., the spinner in fullscreen mode after enough messages accumulate). 61 let absoluteTop = element.yogaNode.getComputedTop() 62 let parent: DOMElement | undefined = element.parentNode 63 let root = element.yogaNode 64 while (parent) { 65 if (parent.yogaNode) { 66 absoluteTop += parent.yogaNode.getComputedTop() 67 root = parent.yogaNode 68 } 69 // scrollTop is only ever set on scroll containers (by ScrollBox + renderer). 70 // Non-scroll nodes have undefined scrollTop → falsy fast-path. 71 if (parent.scrollTop) absoluteTop -= parent.scrollTop 72 parent = parent.parentNode 73 } 74 75 // Only the root's height matters 76 const screenHeight = root.getComputedHeight() 77 78 const bottom = absoluteTop + height 79 // When content overflows the viewport (screenHeight > rows), the 80 // cursor-restore at frame end scrolls one extra row into scrollback. 81 // log-update.ts accounts for this with scrollbackRows = viewportY + 1. 82 // We must match, otherwise an element at the boundary is considered 83 // "visible" here (animation keeps ticking) but its row is treated as 84 // scrollback by log-update (content change → full reset → flicker). 85 const cursorRestoreScroll = screenHeight > rows ? 1 : 0 86 const viewportY = Math.max(0, screenHeight - rows) + cursorRestoreScroll 87 const viewportBottom = viewportY + rows 88 const visible = bottom > viewportY && absoluteTop < viewportBottom 89 90 if (visible !== entryRef.current.isVisible) { 91 entryRef.current = { isVisible: visible } 92 } 93 }) 94 95 return [setElement, entryRef.current] 96 }