/ ink / hooks / use-terminal-viewport.ts
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  }