* A Box with `overflow: scroll` and an imperative scroll API. * * Children are laid out at their full Yoga-computed height inside a * constrained container. At render time, only children intersecting the * visible window (scrollTop..scrollTop+height) are rendered (viewport * culling). Content i
({
children,
ref,
stickyScroll,
...style
}: PropsWithChildren<ScrollBoxProps>)
| 80 | * Works best inside a fullscreen (constrained-height root) Ink tree. |
| 81 | */ |
| 82 | function ScrollBox({ |
| 83 | children, |
| 84 | ref, |
| 85 | stickyScroll, |
| 86 | ...style |
| 87 | }: PropsWithChildren<ScrollBoxProps>): React.ReactNode { |
| 88 | const domRef = useRef<DOMElement>(null); |
| 89 | // scrollTo/scrollBy bypass React: they mutate scrollTop on the DOM node, |
| 90 | // mark it dirty, and call the root's throttled scheduleRender directly. |
| 91 | // The Ink renderer reads scrollTop from the node — no React state needed, |
| 92 | // no reconciler overhead per wheel event. The microtask defer coalesces |
| 93 | // multiple scrollBy calls in one input batch (discreteUpdates) into one |
| 94 | // render — otherwise scheduleRender's leading edge fires on the FIRST |
| 95 | // event before subsequent events mutate scrollTop. scrollToBottom still |
| 96 | // forces a React render: sticky is attribute-observed, no DOM-only path. |
| 97 | const [, forceRender] = useState(0); |
| 98 | const listenersRef = useRef(new Set<() => void>()); |
| 99 | const renderQueuedRef = useRef(false); |
| 100 | const notify = () => { |
| 101 | for (const l of listenersRef.current) l(); |
| 102 | }; |
| 103 | function scrollMutated(el: DOMElement): void { |
| 104 | // Signal background intervals (IDE poll, LSP poll, GCS fetch, orphan |
| 105 | // check) to skip their next tick — they compete for the event loop and |
| 106 | // contributed to 1402ms max frame gaps during scroll drain. |
| 107 | markScrollActivity(); |
| 108 | markDirty(el); |
| 109 | markCommitStart(); |
| 110 | notify(); |
| 111 | if (renderQueuedRef.current) return; |
| 112 | renderQueuedRef.current = true; |
| 113 | queueMicrotask(() => { |
| 114 | renderQueuedRef.current = false; |
| 115 | scheduleRenderFrom(el); |
| 116 | }); |
| 117 | } |
| 118 | useImperativeHandle(ref, (): ScrollBoxHandle => ({ |
| 119 | scrollTo(y: number) { |
| 120 | const el = domRef.current; |
| 121 | if (!el) return; |
| 122 | // Explicit false overrides the DOM attribute so manual scroll |
| 123 | // breaks stickiness. Render code checks ?? precedence. |
| 124 | el.stickyScroll = false; |
| 125 | el.pendingScrollDelta = undefined; |
| 126 | el.scrollAnchor = undefined; |
| 127 | el.scrollTop = Math.max(0, Math.floor(y)); |
| 128 | scrollMutated(el); |
| 129 | }, |
| 130 | scrollToElement(el: DOMElement, offset = 0) { |
| 131 | const box = domRef.current; |
| 132 | if (!box) return; |
| 133 | box.stickyScroll = false; |
| 134 | box.pendingScrollDelta = undefined; |
| 135 | box.scrollAnchor = { |
| 136 | el, |
| 137 | offset |
| 138 | }; |
| 139 | scrollMutated(box); |
nothing calls this directly
no outgoing calls
no test coverage detected