From 2287716a395c184fc912edceb2afc8e868be4c06 Mon Sep 17 00:00:00 2001 From: jacob314 Date: Fri, 3 Apr 2026 19:16:58 -0700 Subject: [PATCH 1/3] Checkpoint refactor: simplify StaticRender layout computation Moved StaticRender's layout evaluation to prepareYogaTree inside ink.tsx. This avoids repeatedly evaluating static subtrees during the main layout loop, treating nested and cached StaticRender components as efficient, fixed-size leaf nodes. fix: address PR review feedback fix: lint errors chore: update selection snapshot refactor: Update StaticRender to take a callback for children Tweak example. fix(worker): expand root region when rendering backbuffer lines This fixes an issue where nested scrollable regions with overflowToBackbuffer=true would not render their newly revealed lines into the backbuffer because the root region artificially clipped the rendering canvas to the terminal height. By passing overrideHeight and isExpanded=true to the root region composeNode call during getLinesForScroll, the scrollable region receives the expanded context and correctly renders its off-screen lines, which are then successfully captured and written to the terminal history. Tests to reproduce issues. --- examples/nested-static/index.ts | 15 + examples/nested-static/nested-static.tsx | 360 +++++++++++++++++++++++ examples/resize-observer/index.tsx | 8 +- examples/scroll/scroll.tsx | 8 +- examples/selection/selection.tsx | 79 ++--- examples/static-render/static-render.tsx | 2 +- examples/sticky/sticky.tsx | 6 +- src/components/StaticRender.tsx | 14 +- src/dom.ts | 30 +- src/global.d.ts | 2 +- src/ink.tsx | 87 +++++- src/reconciler.ts | 21 +- src/render-node-to-output.ts | 3 + src/renderer.ts | 14 - src/squash-text-nodes.ts | 41 ++- src/worker/render-worker.ts | 2 + test/backbuffer.tsx | 9 +- test/helpers/svg.ts | 4 +- test/nested-static-border.test.tsx | 86 ++++++ test/nested-static-gap.test.tsx | 105 +++++++ test/repro-bug-nested-static.test.ts | 82 ++++++ test/repro-bug-scroll.test.ts | 76 +++++ test/repro-sticky-static.tsx | 32 +- test/repro-virtualized-static.tsx | 72 +++-- test/resize-observer.tsx | 12 +- test/static-render-nested.test.tsx | 62 ++++ test/static-render-sticky-children.tsx | 38 +-- test/static-render-sticky.tsx | 91 +++--- test/static-render.tsx | 16 +- test/sticky-inline-bug.test.tsx | 2 +- 30 files changed, 1156 insertions(+), 223 deletions(-) create mode 100644 examples/nested-static/index.ts create mode 100644 examples/nested-static/nested-static.tsx create mode 100644 test/nested-static-border.test.tsx create mode 100644 test/nested-static-gap.test.tsx create mode 100644 test/repro-bug-nested-static.test.ts create mode 100644 test/repro-bug-scroll.test.ts create mode 100644 test/static-render-nested.test.tsx diff --git a/examples/nested-static/index.ts b/examples/nested-static/index.ts new file mode 100644 index 000000000..357995092 --- /dev/null +++ b/examples/nested-static/index.ts @@ -0,0 +1,15 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import {render} from '../../src/index.js'; +import NestedStaticDemo from './nested-static.js'; + +render(React.createElement(NestedStaticDemo), { + renderProcess: true, + terminalBuffer: true, + incrementalRendering: true, +}); diff --git a/examples/nested-static/nested-static.tsx b/examples/nested-static/nested-static.tsx new file mode 100644 index 000000000..3ac21bb55 --- /dev/null +++ b/examples/nested-static/nested-static.tsx @@ -0,0 +1,360 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import process from 'node:process'; +import React, { + useState, + useEffect, + useReducer, + useRef, + useLayoutEffect, +} from 'react'; +import { + Box, + Text, + StaticRender, + useInput, + getInnerHeight, + getScrollHeight, + type DOMElement, +} from '../../src/index.js'; + +const renderCounts = new Map(); + +function TrackedText({ + name, + children, + color, +}: { + readonly name: string; + readonly children: React.ReactNode; + readonly color?: string; +}) { + return ( + + { + const count = (renderCounts.get(name) || 0) + 1; + renderCounts.set(name, count); + return `${text} [Rebuilt: ${count}]`; + }} + > + {children} + + + ); +} + +type ScrollState = { + scrollTop: number; +}; + +type ScrollAction = + | {type: 'up'; delta: number} + | {type: 'down'; delta: number; max: number} + | {type: 'setTop'; value: number}; + +function scrollReducer(state: ScrollState, action: ScrollAction): ScrollState { + switch (action.type) { + case 'up': { + return { + ...state, + scrollTop: Math.max(0, state.scrollTop - action.delta), + }; + } + + case 'down': { + return { + ...state, + scrollTop: Math.min(state.scrollTop + action.delta, action.max), + }; + } + + case 'setTop': { + return { + ...state, + scrollTop: action.value, + }; + } + } +} + +export function useTerminalSize(): {columns: number; rows: number} { + const [size, setSize] = useState({ + columns: process.stdout.columns || 80, + rows: process.stdout.rows || 20, + }); + + useEffect(() => { + const updateSize = () => { + setSize({ + columns: process.stdout.columns || 80, + rows: process.stdout.rows || 20, + }); + }; + + process.stdout.on('resize', updateSize); + + return () => { + process.stdout.off('resize', updateSize); + }; + }, []); + + return size; +} + +const InnerStatic = React.memo(({id}: {readonly id: string}) => { + return ( + + {() => ( + + + Inner Item {id} + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do + eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim + ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut + aliquip ex ea commodo consequat. Duis aute irure dolor in + reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla. + + {id === '1-1' && ( + + + {() => ( + + + Nested Item 1 + + + Nested Item 2 + + + Nested Item 3 + + + )} + + + )} + + )} + + ); +}); + +const OuterGroup = React.memo( + ({groupId, items}: {readonly groupId: number; readonly items: number[]}) => { + return ( + + + Outer Group {groupId} + + + + {items.map(itemId => ( + + ))} + + + ); + }, +); + +export default function NestedStaticDemo() { + const [count, setCount] = useState(0); + const [groups, setGroups] = useState>([ + {id: 1, items: Array.from({length: 1000}, (_, i) => i + 1)}, + ]); + const [scrollState, dispatch] = useReducer(scrollReducer, {scrollTop: 0}); + const {scrollTop} = scrollState; + const {columns, rows} = useTerminalSize(); + + const reference = useRef(null); + const sizeReference = useRef({innerHeight: 0, scrollHeight: 0}); + const [shouldScrollToBottom, setShouldScrollToBottom] = useState(true); + + useLayoutEffect(() => { + if (reference.current) { + const innerHeight = getInnerHeight(reference.current); + const scrollHeight = getScrollHeight(reference.current); + + sizeReference.current = {innerHeight, scrollHeight}; + + if (shouldScrollToBottom) { + dispatch({ + type: 'setTop', + value: Math.max(0, scrollHeight - innerHeight), + }); + setShouldScrollToBottom(false); + } + } + }); + + const [nextItemId, setNextItemId] = useState(1001); + + useInput((input, key) => { + if (input === ' ') { + setGroups(previousGroups => { + const newGroups = [...previousGroups]; + const firstGroup = newGroups[0]; + if (firstGroup) { + newGroups[0] = { + ...firstGroup, + items: [...firstGroup.items, nextItemId], + }; + setNextItemId(id => id + 1); + } + + return newGroups; + }); + setShouldScrollToBottom(true); + return; + } + + if (input === 'n') { + setGroups(previousGroups => { + const nextGroupId = + previousGroups.length > 0 + ? Math.max(...previousGroups.map(g => g.id)) + 1 + : 1; + return [...previousGroups, {id: nextGroupId, items: [1]}]; + }); + setShouldScrollToBottom(true); + return; + } + + if (key.upArrow || input === 'w') { + dispatch({type: 'up', delta: key.shift ? 10 : 1}); + return; + } + + if (key.downArrow || input === 's') { + dispatch({ + type: 'down', + delta: key.shift ? 10 : 1, + max: Math.max( + 0, + sizeReference.current.scrollHeight - + sizeReference.current.innerHeight, + ), + }); + return; + } + + if (input === 'u') { + dispatch({ + type: 'up', + delta: Math.floor(sizeReference.current.scrollHeight / 2), + }); + return; + } + + if (input === 'd') { + dispatch({ + type: 'down', + delta: Math.floor(sizeReference.current.scrollHeight / 2), + max: Math.max( + 0, + sizeReference.current.scrollHeight - + sizeReference.current.innerHeight, + ), + }); + } + }); + + useEffect(() => { + const timer = setInterval(() => { + setCount(previous => previous + 1); + }, 5000); + + const addTimer = setInterval(() => { + setGroups(previousGroups => { + const newGroups = [...previousGroups]; + const firstGroup = newGroups[0]; + if (firstGroup) { + newGroups[0] = { + ...firstGroup, + items: [...firstGroup.items, nextItemId], + }; + setNextItemId(id => id + 1); + } + + return newGroups; + }); + setShouldScrollToBottom(true); + }, 5000); + + return () => { + clearInterval(timer); + clearInterval(addTimer); + }; + }, [nextItemId]); + + return ( + + + + {groups.map(group => ( + + ))} + + + + + Nested StaticRender Demo + Press [Space] to add item to Group 1, [n] to add new Group. + + Arrows to scroll. [u]/[d] scroll up/down by half total height. + ScrollTop: {scrollTop} + + Timer: {count} + + + ); +} diff --git a/examples/resize-observer/index.tsx b/examples/resize-observer/index.tsx index a3884d2fa..d58282c5a 100644 --- a/examples/resize-observer/index.tsx +++ b/examples/resize-observer/index.tsx @@ -116,9 +116,11 @@ function App() { - - I am a StaticRender block to test layout sizing. - + {() => ( + + I am a StaticRender block to test layout sizing. + + )} diff --git a/examples/scroll/scroll.tsx b/examples/scroll/scroll.tsx index 69a737745..cd346c788 100644 --- a/examples/scroll/scroll.tsx +++ b/examples/scroll/scroll.tsx @@ -438,8 +438,12 @@ function ScrollableContent({ if (useStatic) { return ( - START OF STATIC BLOCK - {children} + {() => ( + <> + START OF STATIC BLOCK + {children} + + )} ); } diff --git a/examples/selection/selection.tsx b/examples/selection/selection.tsx index 4969ad252..9481936dc 100644 --- a/examples/selection/selection.tsx +++ b/examples/selection/selection.tsx @@ -450,45 +450,54 @@ const Selection = forwardRef( > {useStaticRender ? ( - - - Hello World - - - - This is a test - - - + {() => ( + - Row A + Hello{' '} + World - - Row B - - - {longText} - {insertedLines.map(line => ( - {line} - ))} - - {codeExample.map((line, i) => ( - - - {i + 1} - - - {' '.repeat(line.indent)} - {line.content} - - + + + This is a test + + + + + Row A + + + Row B + + + {longText} + {insertedLines.map(line => ( + {line} ))} + + {codeExample.map((line, i) => ( + + + {i + 1} + + + {' '.repeat(line.indent)} + {line.content} + + + ))} + - + )} ) : ( diff --git a/examples/static-render/static-render.tsx b/examples/static-render/static-render.tsx index 4da0e2fc4..02253a757 100644 --- a/examples/static-render/static-render.tsx +++ b/examples/static-render/static-render.tsx @@ -43,7 +43,7 @@ export default function Example() { return ( - {heavyContent} + {() => heavyContent} Counter: {counter} diff --git a/examples/sticky/sticky.tsx b/examples/sticky/sticky.tsx index d729c470e..8886af8cc 100644 --- a/examples/sticky/sticky.tsx +++ b/examples/sticky/sticky.tsx @@ -265,7 +265,7 @@ function ScrollableContent({ key={`static-inner-scroll-${headerId}`} width={contentWidth} > - {innerBox} + {() => innerBox} ) : ( innerBox @@ -348,7 +348,7 @@ function ScrollableContent({ elements.push( useStatic ? ( - {groupInnerBox} + {() => groupInnerBox} ) : ( groupInnerBox @@ -364,7 +364,7 @@ function ScrollableContent({ return useStatic ? ( - {itemInnerBox} + {() => itemInnerBox} ) : ( itemInnerBox diff --git a/src/components/StaticRender.tsx b/src/components/StaticRender.tsx index 3750d7148..b9f5f1f85 100644 --- a/src/components/StaticRender.tsx +++ b/src/components/StaticRender.tsx @@ -1,16 +1,16 @@ -import React, {useRef, useEffect, type ReactNode} from 'react'; +import React, {useRef, useEffect, useState, type ReactNode} from 'react'; import {type DOMElement} from '../dom.js'; -import {renderToStatic} from '../render-node-to-output.js'; import {type Styles} from '../styles.js'; export type Props = { - readonly children: ReactNode; + readonly children: () => ReactNode; readonly width: number; readonly style?: Styles; }; export default function StaticRender({children, width, style}: Props) { const ref = useRef(null); + const [isRendered, setIsRendered] = useState(false); useEffect(() => { const node = ref.current; @@ -25,13 +25,11 @@ export default function StaticRender({children, width, style}: Props) { { - if (node && !node.cachedRender) { - renderToStatic(node); - } + internal_onRendered={() => { + setIsRendered(true); }} > - {children} + {isRendered ? null : children()} ); } diff --git a/src/dom.ts b/src/dom.ts index f24ba83e1..b2c77e18f 100644 --- a/src/dom.ts +++ b/src/dom.ts @@ -63,11 +63,12 @@ export type DOMElement = { internal_transform?: OutputTransformer; internal_terminalCursorFocus?: boolean; internal_terminalCursorPosition?: number; - internalOnBeforeRender?: ( - node: DOMElement, - options?: {trackSelection?: boolean}, - ) => void; + internal_onRendered?: () => void; cachedRender?: Region; + internal_textCache?: { + text: string; + map: Map; + }; internal_accessibility?: { role?: @@ -104,6 +105,7 @@ export type DOMElement = { // Internal properties isStaticDirty?: boolean; + isYogaTreeDetached?: boolean; staticNode?: DOMElement; onComputeLayout?: () => void; onRender?: () => void; @@ -334,13 +336,31 @@ export const setCachedRender = (node: DOMElement, cachedRender: Region) => { while (node.yogaNode.getChildCount() > 0) { node.yogaNode.removeChild(node.yogaNode.getChild(0)); } + + node.isYogaTreeDetached = true; } }; -const markNodeAsDirty = (node?: DOMNode): void => { +export const markNodeAsDirty = (node?: DOMNode): void => { // Mark closest Yoga node as dirty to measure text dimensions again const yogaNode = findClosestYogaNode(node); yogaNode?.markDirty(); + + let current = node; + while (current) { + if ( + current.nodeName === 'ink-text' || + current.nodeName === 'ink-virtual-text' + ) { + current.internal_textCache = undefined; + } + + if ('cachedRender' in current) { + current.cachedRender = undefined; + } + + current = current.parentNode; + } }; export const setTextNodeValue = (node: TextNode, text: string): void => { diff --git a/src/global.d.ts b/src/global.d.ts index 3150f2bf6..5b50ce8dc 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -34,8 +34,8 @@ declare namespace Ink { children?: ReactNode; style?: Styles; ref?: LegacyRef; - internalOnBeforeRender?: (node: DOMElement) => void; cachedRender?: Region; + internal_onRendered?: () => void; }; type Text = { diff --git a/src/ink.tsx b/src/ink.tsx index ef3021eb9..4baf9a3e4 100644 --- a/src/ink.tsx +++ b/src/ink.tsx @@ -11,10 +11,12 @@ import {LegacyRoot} from 'react-reconciler/constants.js'; import {type FiberRoot} from 'react-reconciler'; import Yoga from 'yoga-layout'; import wrapAnsi from 'wrap-ansi'; +import applyStyles from './styles.js'; import reconciler from './reconciler.js'; import render from './renderer.js'; import * as dom from './dom.js'; import logUpdate, {type LogUpdate, positionImeCursor} from './log-update.js'; +import {renderToStatic} from './render-node-to-output.js'; import instances from './instances.js'; import App from './components/App.js'; import {type InkOptions} from './components/AppContext.js'; @@ -309,6 +311,17 @@ export default class Ink { unsubscribeExit: () => void = () => {}; calculateLayout = () => { + const flushLayoutObservers = (node: dom.DOMElement, isResized: boolean) => { + const observerEntries = new Map(); + this.calculateLayoutAndTriggerObservers(node, observerEntries, isResized); + + for (const [observer, entries] of observerEntries) { + observer.internalTrigger(entries); + } + }; + + this.prepareYogaTree(this.rootNode); + // The 'columns' property can be undefined or 0 when not using a TTY. // In that case we fall back to 80. const terminalWidth = this.options.stdout.columns ?? 80; @@ -321,17 +334,8 @@ export default class Ink { Yoga.DIRECTION_LTR, ); - const observerEntries = new Map(); - this.calculateLayoutAndTriggerObservers( - this.rootNode, - observerEntries, - this.isTerminalResized, - ); + flushLayoutObservers(this.rootNode, this.isTerminalResized); this.isTerminalResized = false; - - for (const [observer, entries] of observerEntries) { - observer.internalTrigger(entries); - } }; calculateLayoutAndTriggerObservers( @@ -842,7 +846,70 @@ export default class Ink { this.lastCursorPosition = cursorPosition; } + private prepareYogaTree(node: dom.DOMElement) { + const flushLayoutObservers = (node: dom.DOMElement, isResized: boolean) => { + const observerEntries = new Map(); + this.calculateLayoutAndTriggerObservers(node, observerEntries, isResized); + + for (const [observer, entries] of observerEntries) { + observer.internalTrigger(entries); + } + }; + + if (node.isYogaTreeDetached && !node.cachedRender && node.yogaNode) { + // Re-apply styles to revert any fixed width/height set by setCachedRender + node.yogaNode.setWidthAuto(); + node.yogaNode.setHeightAuto(); + applyStyles(node.yogaNode, node.style); + + while (node.yogaNode.getChildCount() > 0) { + node.yogaNode.removeChild(node.yogaNode.getChild(0)); + } + + let yogaIndex = 0; + for (const child of node.childNodes) { + const domChild = child as dom.DOMElement; + if (child.nodeName !== '#text' && domChild.yogaNode) { + node.yogaNode.insertChild(domChild.yogaNode, yogaIndex); + yogaIndex++; + } + } + + node.isYogaTreeDetached = false; + this.markAllTextNodesDirty(node); + } + + for (const child of node.childNodes) { + if (child.nodeName !== '#text') { + this.prepareYogaTree(child); + } + } + + if (node.nodeName === 'ink-static-render' && !node.cachedRender) { + const terminalWidth = this.options.stdout.columns ?? 80; + const {width} = node.style; + + if (node.yogaNode) { + node.yogaNode.setWidth( + typeof width === 'number' ? width : terminalWidth, + ); + node.yogaNode.calculateLayout(undefined, undefined, Yoga.DIRECTION_LTR); + } + + flushLayoutObservers(node, false); + + renderToStatic(node, { + skipStaticElements: false, + trackSelection: this.options.trackSelection, + }); + } + } + private markAllTextNodesDirty(node: dom.DOMElement) { + if (node.cachedRender) { + return; + } + if (node.nodeName === 'ink-text' && node.yogaNode) { node.yogaNode.markDirty(); } diff --git a/src/reconciler.ts b/src/reconciler.ts index 52f789fba..e76210b04 100644 --- a/src/reconciler.ts +++ b/src/reconciler.ts @@ -15,6 +15,7 @@ import { setTextNodeValue, createNode, setAttribute, + markNodeAsDirty, type DOMNodeAttribute, type TextNode, type ElementNames, @@ -23,6 +24,7 @@ import { } from './dom.js'; import applyStyles, {type Styles} from './styles.js'; import {type OutputTransformer} from './render-node-to-output.js'; +import {type Region} from './output.js'; // We need to conditionally perform devtools connection to avoid // accidentally breaking other third-party code. @@ -230,8 +232,8 @@ export default createReconciler< continue; } - if (key === 'internalOnBeforeRender') { - node.internalOnBeforeRender = value as () => void; + if (key === 'internal_onRendered') { + node.internal_onRendered = value as () => void; continue; } @@ -246,6 +248,11 @@ export default createReconciler< continue; } + if (key === 'cachedRender') { + node.cachedRender = value as Region; + continue; + } + if (key === 'opaque') { node.internalOpaque = value as boolean; continue; @@ -334,6 +341,7 @@ export default createReconciler< if (key === 'internal_transform') { node.internal_transform = value as OutputTransformer; + markNodeAsDirty(node); continue; } @@ -357,8 +365,8 @@ export default createReconciler< continue; } - if (key === 'internalOnBeforeRender') { - node.internalOnBeforeRender = value as (node: DOMElement) => void; + if (key === 'internal_onRendered') { + node.internal_onRendered = value as () => void; continue; } @@ -367,6 +375,11 @@ export default createReconciler< continue; } + if (key === 'cachedRender') { + node.cachedRender = value as Region; + continue; + } + if (key === 'opaque') { node.internalOpaque = Boolean(value); continue; diff --git a/src/render-node-to-output.ts b/src/render-node-to-output.ts index ddc0d9070..90ff6f707 100644 --- a/src/render-node-to-output.ts +++ b/src/render-node-to-output.ts @@ -128,6 +128,9 @@ export const renderToStatic = ( } setCachedRender(node, rootRegion); + if (node.internal_onRendered) { + node.internal_onRendered(); + } }; // After nodes are laid out, render each to output object, which later gets rendered to terminal diff --git a/src/renderer.ts b/src/renderer.ts index d8230c1a6..9375a13be 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -256,20 +256,6 @@ const renderer = ( trackSelection, } = options; - const callBeforeRender = (n: DOMElement) => { - if (typeof n.internalOnBeforeRender === 'function') { - n.internalOnBeforeRender(n, {trackSelection}); - } - - for (const child of n.childNodes) { - if (child.nodeName !== '#text') { - callBeforeRender(child); - } - } - }; - - callBeforeRender(node); - if (node.yogaNode) { if (isScreenReaderEnabled) { const output = renderNodeToScreenReaderOutput(node, { diff --git a/src/squash-text-nodes.ts b/src/squash-text-nodes.ts index 6e2f7a92e..29c1df33b 100644 --- a/src/squash-text-nodes.ts +++ b/src/squash-text-nodes.ts @@ -29,7 +29,22 @@ export const squashTextNodesWithMap = ( map: CharOffsetMap, offsetRef: {current: number}, ): string => { + if (node.internal_textCache) { + const {text, map: cachedMap} = node.internal_textCache; + for (const [k, v] of cachedMap.entries()) { + map.set(k, { + start: v.start + offsetRef.current, + end: v.end + offsetRef.current, + }); + } + + offsetRef.current += text.length; + return text; + } + let text = ''; + const localMap: CharOffsetMap = new Map(); + const localOffsetRef = {current: 0}; for (let index = 0; index < node.childNodes.length; index++) { const childNode = node.childNodes[index]; @@ -39,24 +54,24 @@ export const squashTextNodesWithMap = ( } let nodeText = ''; - const startOffset = offsetRef.current; + const startOffset = localOffsetRef.current; if (childNode.nodeName === '#text') { nodeText = childNode.nodeValue; - map.set(childNode, { + localMap.set(childNode, { start: startOffset, end: startOffset + nodeText.length, }); - offsetRef.current += nodeText.length; + localOffsetRef.current += nodeText.length; } else { if ( childNode.nodeName === 'ink-text' || childNode.nodeName === 'ink-virtual-text' ) { - nodeText = squashTextNodesWithMap(childNode, map, offsetRef); - map.set(childNode, { + nodeText = squashTextNodesWithMap(childNode, localMap, localOffsetRef); + localMap.set(childNode, { start: startOffset, - end: offsetRef.current, + end: localOffsetRef.current, }); } @@ -73,6 +88,20 @@ export const squashTextNodesWithMap = ( text += nodeText; } + node.internal_textCache = { + text, + map: localMap, + }; + + for (const [k, v] of localMap.entries()) { + map.set(k, { + start: v.start + offsetRef.current, + end: v.end + offsetRef.current, + }); + } + + offsetRef.current += text.length; + return text; }; diff --git a/src/worker/render-worker.ts b/src/worker/render-worker.ts index ac5535e2c..62a607d8d 100644 --- a/src/worker/render-worker.ts +++ b/src/worker/render-worker.ts @@ -721,6 +721,8 @@ export class TerminalBufferWorker { { clip: undefined, offsetY: -cameraY, + overrideHeight: this.rows + count, + isExpanded: true, }, {skipStickyHeaders: true, skipScrollbars}, ); diff --git a/test/backbuffer.tsx b/test/backbuffer.tsx index ff25f64f3..3a6e1f1ef 100644 --- a/test/backbuffer.tsx +++ b/test/backbuffer.tsx @@ -48,14 +48,7 @@ test('captures clipped cachedRender content into backbuffer', t => { // We need to bypass type checking to pass cachedRender to ink-static-render function CachedBox(props: any) { - return React.createElement('ink-static-render', { - ...props, - internalOnBeforeRender(node: {cachedRender?: Region}) { - if (node) { - node.cachedRender = cachedRender; - } - }, - }); + return React.createElement('ink-static-render', props); } const {unmount} = render( diff --git a/test/helpers/svg.ts b/test/helpers/svg.ts index abb3d1a5d..7e53b5925 100644 --- a/test/helpers/svg.ts +++ b/test/helpers/svg.ts @@ -94,7 +94,7 @@ export function generateSvgForTerminal(terminal: Terminal): string { // Find the actual number of rows with content to avoid rendering trailing blank space. let contentRows = terminal.rows; for (let y = terminal.rows - 1; y >= 0; y--) { - const line = activeBuffer.getLine(y); + const line = activeBuffer.getLine(y + activeBuffer.viewportY); if (line && line.translateToString(true).trim().length > 0) { contentRows = y + 1; break; @@ -114,7 +114,7 @@ export function generateSvgForTerminal(terminal: Terminal): string { svg += ` \n`; for (let y = 0; y < contentRows; y++) { - const line = activeBuffer.getLine(y); + const line = activeBuffer.getLine(y + activeBuffer.viewportY); if (!line) continue; let currentFgHex: string | undefined = null; diff --git a/test/nested-static-border.test.tsx b/test/nested-static-border.test.tsx new file mode 100644 index 000000000..e7ba0604a --- /dev/null +++ b/test/nested-static-border.test.tsx @@ -0,0 +1,86 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; +import process from 'node:process'; +import test from 'ava'; +import React from 'react'; +import {Box, Text, StaticRender} from '../src/index.js'; +import {render} from './helpers/render.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +const OuterGroup = React.memo(({items}: {readonly items: number[]}) => { + return ( + + {() => ( + + Outer Box + {items.map(id => ( + + {() => ( + + Inner {id} + + )} + + ))} + + )} + + ); +}); + +function TestApp({items}: {readonly items: number[]}) { + return ( + + + + ); +} + +test('Nested StaticRender test', async t => { + const columns = 80; + const rows = 10; + + const {rerender, unmount, waitUntilReady, generateSvg} = await render( + , + columns, + { + terminalHeight: rows, + terminalBuffer: true, + renderProcess: false, + }, + ); + + await waitUntilReady(); + + // Trigger update that clears cachedRender + await rerender(); + await waitUntilReady(); + + const svg = generateSvg(); + const snapshotPath = path.join( + __dirname, + 'snapshots', + 'nested-static', + 'border-update.svg', + ); + + fs.mkdirSync(path.dirname(snapshotPath), {recursive: true}); + + if (process.env['UPDATE_SNAPSHOTS'] ?? !fs.existsSync(snapshotPath)) { + fs.writeFileSync(snapshotPath, svg, 'utf8'); + t.pass(); + } else { + const expected = fs.readFileSync(snapshotPath, 'utf8'); + t.is(svg, expected); + } + + await unmount(); +}); diff --git a/test/nested-static-gap.test.tsx b/test/nested-static-gap.test.tsx new file mode 100644 index 000000000..a1c9bea50 --- /dev/null +++ b/test/nested-static-gap.test.tsx @@ -0,0 +1,105 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; +import process from 'node:process'; +import test from 'ava'; +import React from 'react'; +import {Box, Text, StaticRender} from '../src/index.js'; +import {render} from './helpers/render.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +const OuterGroup = React.memo(({items}: {readonly items: number[]}) => { + return ( + + {() => ( + + Outer Box + {items.map(id => ( + + {() => ( + + Inner {id} + + )} + + ))} + + )} + + ); +}); + +function TestApp({itemCount}: {readonly itemCount: number}) { + const items = Array.from({length: itemCount}).map((_, i) => i + 1); + + const expectedHeight = 2 + 3 * itemCount; + const scrollTop = Math.max(0, expectedHeight - 10); + + return ( + + + + + + ); +} + +test('Multiple additions to nested StaticRender do not leave gaps', async t => { + const columns = 80; + const rows = 10; + + const {rerender, unmount, waitUntilReady, generateSvg} = await render( + , + columns, + { + terminalHeight: rows, + terminalBuffer: true, + renderProcess: false, + standardReactLayoutTiming: false, + maxFps: 1000, + }, + ); + + await waitUntilReady(); + + for (let i = 2; i <= 5; i++) { + // eslint-disable-next-line no-await-in-loop + await rerender(); + // eslint-disable-next-line no-await-in-loop + await waitUntilReady(); + } + + const svg = generateSvg(); + const snapshotPath = path.join( + __dirname, + 'snapshots', + 'nested-static', + 'gap-update.svg', + ); + + fs.mkdirSync(path.dirname(snapshotPath), {recursive: true}); + + if (process.env['UPDATE_SNAPSHOTS'] ?? !fs.existsSync(snapshotPath)) { + fs.writeFileSync(snapshotPath, svg, 'utf8'); + t.pass(); + } else { + const expected = fs.readFileSync(snapshotPath, 'utf8'); + t.is(svg, expected); + } + + await unmount(); +}); diff --git a/test/repro-bug-nested-static.test.ts b/test/repro-bug-nested-static.test.ts new file mode 100644 index 000000000..fca8e8261 --- /dev/null +++ b/test/repro-bug-nested-static.test.ts @@ -0,0 +1,82 @@ +import test from 'ava'; +import {TerminalBufferWorker} from '../src/worker/render-worker.js'; +import xtermHeadless from '@xterm/headless'; +import {Serializer} from '../src/serialization.js'; +import {createStyledLine} from './helpers/replay-lib.js'; + +const {Terminal} = xtermHeadless; + +test('TerminalBufferWorker generates correct backbuffer during scroll', async t => { + const columns = 80; + const rows = 10; + let output = ''; + const stdout = { + write(chunk: string) { + output += chunk; + return true; + }, + on() {}, + rows, + columns, + } as unknown as NodeJS.WriteStream; + + const worker = new TerminalBufferWorker(columns, rows, {stdout}); + const serializer = new Serializer(); + + // Create lines 0 to 50 + const lines = Array.from({length: 50}, (_, i) => createStyledLine(`Line ${i}`)); + const data = serializer.serialize(lines); + + // First render (scrollTop 0) + worker.update({ + id: 'root', + children: [{id: 'scroll', children: []}] + }, [ + { + id: 'root', + y: 0, + width: columns, + height: rows, + lines: {updates: [], totalLength: 0} + }, + { + id: 'scroll', + y: 0, + width: columns, + height: rows, + isScrollable: true, + overflowToBackbuffer: true, + scrollTop: 0, + scrollHeight: 50, + scrollWidth: columns, + lines: { + updates: [{start: 0, end: 50, data}], + totalLength: 50 + } + } + ]); + + await worker.render(); + + // Scroll to bottom + worker.update({ + id: 'root', + children: [{id: 'scroll', children: []}] + }, [ + { + id: 'scroll', + scrollTop: 40 + } + ]); + + await worker.render(); + + const term = new Terminal({cols: columns, rows, allowProposedApi: true, convertEol: true}); + await new Promise((resolve) => term.write(output, resolve)); + + // Expect the backbuffer to have lines 0 to 39 + // And the screen to have lines 40 to 49 + t.is(term.buffer.active.getLine(0)?.translateToString(true).trim(), 'Line 0'); + t.is(term.buffer.active.getLine(39)?.translateToString(true).trim(), 'Line 39'); + t.is(term.buffer.active.getLine(40)?.translateToString(true).trim(), 'Line 40'); +}); diff --git a/test/repro-bug-scroll.test.ts b/test/repro-bug-scroll.test.ts new file mode 100644 index 000000000..05417145c --- /dev/null +++ b/test/repro-bug-scroll.test.ts @@ -0,0 +1,76 @@ +import test from 'ava'; +import {TerminalBufferWorker} from '../src/worker/render-worker.js'; +import xtermHeadless from '@xterm/headless'; +import {Serializer} from '../src/serialization.js'; +import {createStyledLine} from './helpers/replay-lib.js'; + +const {Terminal} = xtermHeadless; + +test('TerminalBufferWorker scroll down without backbuffer', async t => { + const columns = 80; + const rows = 10; + let output = ''; + const stdout = { + write(chunk: string) { + output += chunk; + return true; + }, + on() {}, + rows, + columns, + } as unknown as NodeJS.WriteStream; + + const worker = new TerminalBufferWorker(columns, rows, {stdout}); + const serializer = new Serializer(); + + const lines = Array.from({length: 20}, (_, i) => createStyledLine(`Line ${i}`)); + const data = serializer.serialize(lines); + + worker.update({ + id: 'root', + children: [{id: 'scroll', children: []}] + }, [ + { + id: 'root', + y: 0, + width: columns, + height: rows, + lines: {updates: [], totalLength: 0} + }, + { + id: 'scroll', + y: 0, + width: columns, + height: rows, + isScrollable: true, + overflowToBackbuffer: false, + scrollTop: 0, + scrollHeight: 20, + scrollWidth: columns, + lines: { + updates: [{start: 0, end: 20, data}], + totalLength: 20 + } + } + ]); + + await worker.render(); + + worker.update({ + id: 'root', + children: [{id: 'scroll', children: []}] + }, [ + { + id: 'scroll', + scrollTop: 1 + } + ]); + + await worker.render(); + + const term = new Terminal({cols: columns, rows, allowProposedApi: true, convertEol: true}); + await new Promise((resolve) => term.write(output, resolve)); + + // Expect the screen to have lines 1 to 10 + t.is(term.buffer.active.getLine(9)?.translateToString(true).trim(), 'Line 10'); +}); diff --git a/test/repro-sticky-static.tsx b/test/repro-sticky-static.tsx index f1168214b..f6f749369 100644 --- a/test/repro-sticky-static.tsx +++ b/test/repro-sticky-static.tsx @@ -15,22 +15,24 @@ test('sticky inside static render boundary test 4', t => { borderStyle="single" > - - - STICKY HEADER 1 + {() => ( + + + STICKY HEADER 1 + + {Array.from({length: 5}).map((_, i) => { + const key = `LineA-${i}`; + return Line A{i}; + })} + + STICKY HEADER 2 + + {Array.from({length: 5}).map((_, i) => { + const key = `LineB-${i}`; + return Line B{i}; + })} - {Array.from({length: 5}).map((_, i) => { - const key = `LineA-${i}`; - return Line A{i}; - })} - - STICKY HEADER 2 - - {Array.from({length: 20}).map((_, i) => { - const key = `LineB-${i}`; - return Line B{i}; - })} - + )} , ); diff --git a/test/repro-virtualized-static.tsx b/test/repro-virtualized-static.tsx index 82fa4d2b3..5855ab23d 100644 --- a/test/repro-virtualized-static.tsx +++ b/test/repro-virtualized-static.tsx @@ -15,26 +15,30 @@ test('debug static render inside scroll container', t => { > - - - HEADER 1 + {() => ( + + + HEADER 1 + + Item 1 Line 1 + Item 1 Line 2 + Item 1 Line 3 - Item 1 Line 1 - Item 1 Line 2 - Item 1 Line 3 - + )} - - - HEADER 2 + {() => ( + + + HEADER 2 + + Item 2 Line 1 + Item 2 Line 2 + Item 2 Line 3 + Item 2 Line 4 + Item 2 Line 5 - Item 2 Line 1 - Item 2 Line 2 - Item 2 Line 3 - Item 2 Line 4 - Item 2 Line 5 - + )} , @@ -50,26 +54,30 @@ test('debug static render inside scroll container', t => { > - - - HEADER 1 + {() => ( + + + HEADER 1 + + Item 1 Line 1 + Item 1 Line 2 + Item 1 Line 3 - Item 1 Line 1 - Item 1 Line 2 - Item 1 Line 3 - + )} - - - HEADER 2 + {() => ( + + + HEADER 2 + + Item 2 Line 1 + Item 2 Line 2 + Item 2 Line 3 + Item 2 Line 4 + Item 2 Line 5 - Item 2 Line 1 - Item 2 Line 2 - Item 2 Line 3 - Item 2 Line 4 - Item 2 Line 5 - + )} , diff --git a/test/resize-observer.tsx b/test/resize-observer.tsx index 8dd590a5c..8b83d14d6 100644 --- a/test/resize-observer.tsx +++ b/test/resize-observer.tsx @@ -229,7 +229,7 @@ test('ResizeObserver attached to child of a StaticRender element still gets succ const stdout = createStdout(); const {unmount} = render( - + {() => } , {stdout}, ); @@ -251,7 +251,7 @@ test('ResizeObserver inside StaticRender does not yield NaN', async t => { const stdout = createStdout(); const {unmount} = render( - + {() => } , {stdout}, ); @@ -280,9 +280,11 @@ test('ResizeObserver attached to parent of a StaticRender element does not get s > Parent - - Static Content - + {() => ( + + Static Content + + )} , {stdout}, diff --git a/test/static-render-nested.test.tsx b/test/static-render-nested.test.tsx new file mode 100644 index 000000000..fc9584ced --- /dev/null +++ b/test/static-render-nested.test.tsx @@ -0,0 +1,62 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; +import process from 'node:process'; +import test from 'ava'; +import React from 'react'; +import {StaticRender, Text, Box} from '../src/index.js'; +import {render} from './helpers/render.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +test('Nested StaticRender elements render correctly', async t => { + const columns = 100; + const rows = 10; + + const {unmount, waitUntilReady, generateSvg} = await render( + + {() => ( + + Outer text + + {() => Inner text} + + + )} + , + columns, + { + terminalHeight: rows, + terminalBuffer: true, + renderProcess: false, + }, + ); + + await waitUntilReady(); + + const svg = generateSvg(); + const snapshotPath = path.join( + __dirname, + 'snapshots', + 'nested-static', + 'simple-nested.svg', + ); + + fs.mkdirSync(path.dirname(snapshotPath), {recursive: true}); + + if (process.env['UPDATE_SNAPSHOTS'] ?? !fs.existsSync(snapshotPath)) { + fs.writeFileSync(snapshotPath, svg, 'utf8'); + t.pass(); + } else { + const expected = fs.readFileSync(snapshotPath, 'utf8'); + t.is(svg, expected); + } + + await unmount(); +}); diff --git a/test/static-render-sticky-children.tsx b/test/static-render-sticky-children.tsx index e2465ce1a..c034a86a5 100644 --- a/test/static-render-sticky-children.tsx +++ b/test/static-render-sticky-children.tsx @@ -27,26 +27,28 @@ test('StaticRender with stickyChildren (different height)', t => { scrollTop={scrollTop} > - - - - STICKY HEADER LINE 1 - STICKY HEADER LINE 2 - - } - > - Normal Header + {() => ( + + + + STICKY HEADER LINE 1 + STICKY HEADER LINE 2 + + } + > + NATURAL HEADER + + Item 1 + Item 2 + Item 3 - Item 1 - Item 2 - Item 3 + End of list - End of list - + )} , ); diff --git a/test/static-render-sticky.tsx b/test/static-render-sticky.tsx index b146680c6..e6afbd0cf 100644 --- a/test/static-render-sticky.tsx +++ b/test/static-render-sticky.tsx @@ -32,17 +32,19 @@ test('StaticRender with sticky header', t => { scrollTop={scrollTop} > - - - - Header + {() => ( + + + + Header + + Item 1 + Item 2 + Item 3 - Item 1 - Item 2 - Item 3 + End of list - End of list - + )} , ); @@ -77,24 +79,27 @@ test('StaticRender containing multiple sticky headers', t => { scrollTop={scrollTop} > - - - - Header 1 + {() => ( + + + + Header 1 + + Item 1-1 + Item 1-2 + Item 1-3 - Item 1-1 - Item 1-2 - Item 1-3 - - - - Header 2 + + + Header 2 + + Item 2-1 + Item 2-2 + Item 2-3 - Item 2-1 - Item 2-2 - Item 2-3 + End of list - + )} , ); @@ -113,25 +118,27 @@ test('StaticRender with multi-line sticky header', t => { scrollTop={5} > - - - STICKY LINE 1 - STICKY LINE 2 - - } - > - Normal Header + {() => ( + + + STICKY LINE 1 + STICKY LINE 2 + + } + > + Normal Header + + {Array.from({length: 20}).map((_, i) => { + const text = `Line ${i}`; + return {text}; + })} - {Array.from({length: 20}).map((_, i) => { - const text = `Line ${i}`; - return {text}; - })} - + )} , ); diff --git a/test/static-render.tsx b/test/static-render.tsx index 28dc60395..259ed3de2 100644 --- a/test/static-render.tsx +++ b/test/static-render.tsx @@ -15,9 +15,7 @@ import createStdout from './helpers/create-stdout.js'; test('StaticRender renders children', async t => { const stdout = createStdout(); const {unmount} = render( - - Hello Static - , + {() => Hello Static}, {stdout}, ); @@ -34,10 +32,12 @@ test('StaticRender with Box and multiple children', async t => { const stdout = createStdout(); const {unmount} = render( - - Line 1 - Line 2 - + {() => ( + + Line 1 + Line 2 + + )} , {stdout}, ); @@ -66,7 +66,7 @@ test('StaticRender respects style prop', async t => { const stdout = createStdout(); const {unmount} = render( - Indented + {() => Indented} , {stdout}, ); diff --git a/test/sticky-inline-bug.test.tsx b/test/sticky-inline-bug.test.tsx index 98dfa93e2..7f309ede5 100644 --- a/test/sticky-inline-bug.test.tsx +++ b/test/sticky-inline-bug.test.tsx @@ -105,7 +105,7 @@ for (const { height={5} scrollTop={scrollTop} > - {content} + {() => content} , {stdin, stdout, debug: true}, From b3577c33b8c832eeac57c6a19e696ac4c624f5d1 Mon Sep 17 00:00:00 2001 From: jacob314 Date: Wed, 8 Apr 2026 02:15:18 -0700 Subject: [PATCH 2/3] fix: resolve memory leak retaining unmounted nodes in StaticRender Update cachedStickyHeaders mapping in render-node-to-output to set 'node: undefined' to drop the retained reference to the DOM element after computing the sticky header layout cache. Update all usages to handle the optional stickyNode property and resolve max-params typescript errors. --- src/render-container.ts | 2 +- src/render-node-to-output.ts | 109 ++++--- src/render-sticky.ts | 33 +-- src/worker/canvas.ts | 46 +-- src/worker/compositor.ts | 132 +++++---- test/repro-bug-nested-static.test.ts | 98 ++++--- test/repro-bug-scroll.test.ts | 93 +++--- .../snapshots/nested-static/simple-nested.svg | 10 + .../selection-snapshot/selection-initial.svg | 276 ++++++++---------- .../selection-static-render.svg | 163 ++++++----- .../static-render-sticky-children.tsx.md | 4 +- .../static-render-sticky-children.tsx.snap | Bin 275 -> 282 bytes test/snapshots/static-render-sticky.tsx.md | 4 +- test/snapshots/static-render-sticky.tsx.snap | Bin 469 -> 472 bytes 14 files changed, 536 insertions(+), 434 deletions(-) create mode 100644 test/snapshots/nested-static/simple-nested.svg diff --git a/src/render-container.ts b/src/render-container.ts index 43c17a87b..da76a504d 100644 --- a/src/render-container.ts +++ b/src/render-container.ts @@ -67,7 +67,7 @@ export function handleContainerNode( let childrenOffsetY = y; let childrenOffsetX = x; const activeStickyNodes: Array<{ - stickyNode: DOMElement; + stickyNode?: DOMElement; type: 'top' | 'bottom'; nextStickyNode?: DOMElement; nextStickyNodeInfo?: StickyNodeInfo; diff --git a/src/render-node-to-output.ts b/src/render-node-to-output.ts index 90ff6f707..246ceb560 100644 --- a/src/render-node-to-output.ts +++ b/src/render-node-to-output.ts @@ -43,57 +43,100 @@ export const renderToStatic = ( const stickyNodes = getStickyDescendants(node); const cachedStickyHeaders: StickyHeader[] = []; - for (const {node: stickyNode} of stickyNodes) { - const {naturalLines, stuckLines, naturalHeight, maxHeaderHeight} = - renderStickyNode(stickyNode, { + for (const stickyNodeInfo of stickyNodes) { + const {node: stickyNode, type: stickyType, cached, anchor} = stickyNodeInfo; + + let naturalLines; + let stuckLines; + let naturalHeight; + let maxHeaderHeight; + + let relativeX: number; + let relativeY: number; + let parentRelativeTop: number; + let parentHeight: number; + let parentBorderTop: number; + let parentBorderBottom: number; + let nodeId: number; + + const currentBorderTop = + node.yogaNode?.getComputedBorder(Yoga.EDGE_TOP) ?? 0; + const currentBorderLeft = + node.yogaNode?.getComputedBorder(Yoga.EDGE_LEFT) ?? 0; + + if (cached && anchor) { + naturalLines = cached.lines; + stuckLines = cached.stuckLines; + naturalHeight = cached.endRow - cached.startRow; + maxHeaderHeight = cached.height!; + + const staticRenderPosTop = getRelativeTop(anchor, node) ?? 0; + const staticRenderPosLeft = getRelativeLeft(anchor, node) ?? 0; + + relativeX = staticRenderPosLeft + cached.relativeX!; + relativeY = staticRenderPosTop + cached.relativeY!; + parentRelativeTop = staticRenderPosTop + cached.parentRelativeTop!; + parentHeight = cached.parentHeight!; + parentBorderTop = cached.parentBorderTop!; + parentBorderBottom = cached.parentBorderBottom!; + nodeId = cached.nodeId; + } else { + if (!stickyNode) continue; + const rendered = renderStickyNode(stickyNode, { skipStaticElements: options.skipStaticElements ?? false, selectionMap: options.selectionMap, selectionStyle: options.selectionStyle, trackSelection: options.trackSelection, }); + naturalLines = rendered.naturalLines; + stuckLines = rendered.stuckLines; + naturalHeight = rendered.naturalHeight; + maxHeaderHeight = rendered.maxHeaderHeight; - const parent = stickyNode.parentNode; - const parentYogaNode = parent?.yogaNode; - const currentBorderTop = - node.yogaNode?.getComputedBorder(Yoga.EDGE_TOP) ?? 0; - const naturalRow = getRelativeTop(stickyNode, node) ?? 0 - currentBorderTop; - const stickyType: 'top' | 'bottom' = - stickyNode.internalSticky === 'bottom' ? 'bottom' : 'top'; + relativeX = (getRelativeLeft(stickyNode, node) ?? 0) - currentBorderLeft; + relativeY = (getRelativeTop(stickyNode, node) ?? 0) - currentBorderTop; - const headerObj = { - nodeId: stickyNode.internalId, + const parent = stickyNode.parentNode; + const parentYogaNode = parent?.yogaNode; + parentRelativeTop = parent + ? (getRelativeTop(parent, node) ?? 0) - currentBorderTop + : 0; + parentHeight = parentYogaNode + ? parentYogaNode.getComputedHeight() + : Number.MAX_SAFE_INTEGER; + parentBorderTop = parentYogaNode + ? parentYogaNode.getComputedBorder(Yoga.EDGE_TOP) + : 0; + parentBorderBottom = parentYogaNode + ? parentYogaNode.getComputedBorder(Yoga.EDGE_BOTTOM) + : 0; + nodeId = stickyNode.internalId; + } + + const naturalRow = relativeY; + + const headerObj: StickyHeader = { + nodeId, lines: naturalLines, stuckLines, styledOutput: stuckLines ?? naturalLines, - x: - getRelativeLeft(stickyNode, node) ?? - 0 - (node.yogaNode?.getComputedBorder(Yoga.EDGE_LEFT) ?? 0), - y: getRelativeTop(stickyNode, node) ?? 0 - currentBorderTop, + x: relativeX, + y: relativeY, naturalRow, startRow: naturalRow, endRow: naturalRow + naturalHeight, scrollContainerId: -1, isStuckOnly: true, - relativeX: - getRelativeLeft(stickyNode, node) ?? - 0 - (node.yogaNode?.getComputedBorder(Yoga.EDGE_LEFT) ?? 0), - relativeY: getRelativeTop(stickyNode, node) ?? 0 - currentBorderTop, + relativeX, + relativeY, height: maxHeaderHeight, type: stickyType, - parentRelativeTop: parent - ? (getRelativeTop(parent, node) ?? 0 - currentBorderTop) - : 0, - parentHeight: parentYogaNode - ? parentYogaNode.getComputedHeight() - : Number.MAX_SAFE_INTEGER, - parentBorderTop: parentYogaNode - ? parentYogaNode.getComputedBorder(Yoga.EDGE_TOP) - : 0, - parentBorderBottom: parentYogaNode - ? parentYogaNode.getComputedBorder(Yoga.EDGE_BOTTOM) - : 0, - node: stickyNode, + parentRelativeTop, + parentHeight, + parentBorderTop, + parentBorderBottom, + node: undefined, }; cachedStickyHeaders.push(headerObj); diff --git a/src/render-sticky.ts b/src/render-sticky.ts index 94dba5069..a05f3e804 100644 --- a/src/render-sticky.ts +++ b/src/render-sticky.ts @@ -15,12 +15,11 @@ import {getScrollTop} from './scroll.js'; import {getRelativeTop, getRelativeLeft} from './measure-element.js'; export type StickyNodeInfo = { - node: DOMElement; + node?: DOMElement; type: 'top' | 'bottom'; cached?: StickyHeader; anchor?: DOMElement; }; - export function getStickyDescendants(node: DOMElement): StickyNodeInfo[] { const stickyDescendants: StickyNodeInfo[] = []; @@ -45,14 +44,11 @@ export function getStickyDescendants(node: DOMElement): StickyNodeInfo[] { domChild.cachedRender?.cachedStickyHeaders ) { for (const header of domChild.cachedRender.cachedStickyHeaders) { - if (header.node) { - stickyDescendants.push({ - node: header.node, - type: header.node.internalSticky === 'bottom' ? 'bottom' : 'top', - cached: header, - anchor: domChild, - }); - } + stickyDescendants.push({ + type: header.type ?? 'top', + cached: header, + anchor: domChild, + }); } } else { const overflow = domChild.style.overflow ?? 'visible'; @@ -145,6 +141,7 @@ export function identifyActiveStickyNodes( parentTop = staticRenderPos + cached.parentRelativeTop!; parentHeight = cached.parentHeight!; } else { + if (!stickyNode) continue; if (!stickyNode.yogaNode) continue; stickyNodeTop = getRelativeTop(stickyNode, node) ?? 0; stickyNodeHeight = stickyNode.yogaNode.getComputedHeight(); @@ -181,7 +178,7 @@ export function identifyActiveStickyNodes( } const activeStickyNodes: Array<{ - stickyNode: DOMElement; + stickyNode?: DOMElement; type: 'top' | 'bottom'; nextStickyNode?: DOMElement; nextStickyNodeInfo?: StickyNodeInfo; @@ -238,7 +235,7 @@ export function identifyActiveStickyNodes( export function renderActiveStickyNodes( activeStickyNodes: Array<{ - stickyNode: DOMElement; + stickyNode?: DOMElement; type: 'top' | 'bottom'; nextStickyNode?: DOMElement; nextStickyNodeInfo?: StickyNodeInfo; @@ -296,6 +293,7 @@ export function renderActiveStickyNodes( stickyOffsetX = x + staticRenderPosLeft + cached.relativeX!; stickyNodeId = cached.nodeId; } else { + if (!stickyNode) continue; stickyNodeTop = getRelativeTop(stickyNode, node) ?? 0; const naturalHeight = stickyNode.yogaNode!.getComputedHeight(); const alternateStickyNode = stickyNode.childNodes.find( @@ -324,9 +322,9 @@ export function renderActiveStickyNodes( const parentBorderBottom = cached ? (cached.parentBorderBottom ?? 0) - : (stickyNode.parentNode?.yogaNode?.getComputedBorder(Yoga.EDGE_BOTTOM) ?? - 0); - + : (stickyNode!.parentNode?.yogaNode?.getComputedBorder( + Yoga.EDGE_BOTTOM, + ) ?? 0); const parentBottom = parentTop + parentHeight - parentBorderBottom; let finalStickyY = 0; @@ -369,9 +367,8 @@ export function renderActiveStickyNodes( } else { const parentBorderTop = cached ? (cached.parentBorderTop ?? 0) - : (stickyNode.parentNode?.yogaNode?.getComputedBorder(Yoga.EDGE_TOP) ?? + : (stickyNode!.parentNode?.yogaNode?.getComputedBorder(Yoga.EDGE_TOP) ?? 0); - let minStickyTop = y - currentScrollTop + parentTop + parentBorderTop; const naturalStickyY = y - currentScrollTop + stickyNodeTop; const stuckStickyY = @@ -418,7 +415,7 @@ export function renderActiveStickyNodes( stuckLines = cached.stuckLines; naturalHeight = cached.endRow - cached.startRow; } else { - const rendered = renderStickyNode(stickyNode, { + const rendered = renderStickyNode(stickyNode!, { transformers: newTransformers, skipStaticElements, selectionMap, diff --git a/src/worker/canvas.ts b/src/worker/canvas.ts index 2569a2d9b..1d3cbcad8 100644 --- a/src/worker/canvas.ts +++ b/src/worker/canvas.ts @@ -68,17 +68,25 @@ export class Canvas { /** * Sets a character at the given coordinates, respecting clipping. */ - // eslint-disable-next-line max-params - setChar( - x: number, - y: number, - value: string, - formatFlags: number, - fgColor?: string, - bgColor?: string, - link?: string, - clip?: Rect, - ) { + setChar({ + x, + y, + value, + formatFlags, + fgColor, + bgColor, + link, + clip, + }: { + x: number; + y: number; + value: string; + formatFlags: number; + fgColor?: string; + bgColor?: string; + link?: string; + clip?: Rect; + }) { if (y < 0 || y >= this.height || x < 0 || x >= this.width) { return; } @@ -107,16 +115,16 @@ export class Canvas { */ drawStyledChars(x: number, y: number, chars: StyledLine, clip?: Rect) { for (let i = 0; i < chars.length; i++) { - this.setChar( - x + i, + this.setChar({ + x: x + i, y, - chars.getValue(i), - chars.getFormatFlags(i), - chars.getFgColor(i), - chars.getBgColor(i), - chars.getLink(i), + value: chars.getValue(i), + formatFlags: chars.getFormatFlags(i), + fgColor: chars.getFgColor(i), + bgColor: chars.getBgColor(i), + link: chars.getLink(i), clip, - ); + }); } } diff --git a/src/worker/compositor.ts b/src/worker/compositor.ts index eb88c267f..02a386ff2 100644 --- a/src/worker/compositor.ts +++ b/src/worker/compositor.ts @@ -30,13 +30,19 @@ export class Compositor { constructor(private readonly options: CompositionOptions) {} - drawContent( - canvas: Canvas, - region: Region, - absX: number, - absY: number, - clip: Rect, - ) { + drawContent({ + canvas, + region, + absX, + absY, + clip, + }: { + canvas: Canvas; + region: Region; + absX: number; + absY: number; + clip: Rect; + }) { const scrollTop = region.scrollTop ?? 0; const scrollLeft = region.scrollLeft ?? 0; @@ -78,41 +84,47 @@ export class Compositor { if (region.backgroundColor) { const bgColor = this.getBackgroundStyles(region.backgroundColor); if (bgColor) { - canvas.setChar( - sx, - sy, - val, - line.getFormatFlags(contentX), - line.getFgColor(contentX), + canvas.setChar({ + x: sx, + y: sy, + value: val, + formatFlags: line.getFormatFlags(contentX), + fgColor: line.getFgColor(contentX), bgColor, - line.getLink(contentX), - ); + link: line.getLink(contentX), + }); continue; } } } - canvas.setChar( - sx, - sy, - val, - line.getFormatFlags(contentX), - line.getFgColor(contentX), - line.getBgColor(contentX), - line.getLink(contentX), - ); + canvas.setChar({ + x: sx, + y: sy, + value: val, + formatFlags: line.getFormatFlags(contentX), + fgColor: line.getFgColor(contentX), + bgColor: line.getBgColor(contentX), + link: line.getLink(contentX), + }); } } } } - drawStickyHeaders( - canvas: Canvas, - region: Region, - absX: number, - absY: number, - clip: Rect, - ) { + drawStickyHeaders({ + canvas, + region, + absX, + absY, + clip, + }: { + canvas: Canvas; + region: Region; + absX: number; + absY: number; + clip: Rect; + }) { if (this.options.skipStickyHeaders) { return; } @@ -217,28 +229,34 @@ export class Compositor { for (let sx = hx1; sx < hx2; sx++) { const cx = sx - headerX; if (cx < line.length) { - canvas.setChar( - sx, - sy, - line.getValue(cx), - line.getFormatFlags(cx), - line.getFgColor(cx), - line.getBgColor(cx), - line.getLink(cx), - ); + canvas.setChar({ + x: sx, + y: sy, + value: line.getValue(cx), + formatFlags: line.getFormatFlags(cx), + fgColor: line.getFgColor(cx), + bgColor: line.getBgColor(cx), + link: line.getLink(cx), + }); } } } } } - drawScrollbars( - canvas: Canvas, - region: Region, - absX: number, - absY: number, - clip: Rect, - ) { + drawScrollbars({ + canvas, + region, + absX, + absY, + clip, + }: { + canvas: Canvas; + region: Region; + absX: number; + absY: number; + clip: Rect; + }) { if ( Boolean(this.options.skipScrollbars && region.overflowToBackbuffer) || !region.isScrollable || @@ -304,7 +322,15 @@ export class Compositor { axis: 'vertical', color: region.scrollbarThumbColor, setChar(x, y, value, formatFlags, fgColor, bgColor, link) { - canvas.setChar(x, y, value, formatFlags, fgColor, bgColor, link); + canvas.setChar({ + x, + y, + value, + formatFlags, + fgColor, + bgColor, + link, + }); }, getExistingChar(x, y) { return canvas.getChar(x, y); @@ -348,7 +374,15 @@ export class Compositor { axis: 'horizontal', color: region.scrollbarThumbColor, setChar(x, y, value, formatFlags, fgColor, bgColor, link) { - canvas.setChar(x, y, value, formatFlags, fgColor, bgColor, link); + canvas.setChar({ + x, + y, + value, + formatFlags, + fgColor, + bgColor, + link, + }); }, getExistingChar(x, y) { return canvas.getChar(x, y); diff --git a/test/repro-bug-nested-static.test.ts b/test/repro-bug-nested-static.test.ts index fca8e8261..4e0d91782 100644 --- a/test/repro-bug-nested-static.test.ts +++ b/test/repro-bug-nested-static.test.ts @@ -22,61 +22,81 @@ test('TerminalBufferWorker generates correct backbuffer during scroll', async t const worker = new TerminalBufferWorker(columns, rows, {stdout}); const serializer = new Serializer(); - + // Create lines 0 to 50 - const lines = Array.from({length: 50}, (_, i) => createStyledLine(`Line ${i}`)); + const lines = Array.from({length: 50}, (_, i) => + createStyledLine(`Line ${i}`), + ); const data = serializer.serialize(lines); // First render (scrollTop 0) - worker.update({ - id: 'root', - children: [{id: 'scroll', children: []}] - }, [ + worker.update( { id: 'root', - y: 0, - width: columns, - height: rows, - lines: {updates: [], totalLength: 0} + children: [{id: 'scroll', children: []}], }, - { - id: 'scroll', - y: 0, - width: columns, - height: rows, - isScrollable: true, - overflowToBackbuffer: true, - scrollTop: 0, - scrollHeight: 50, - scrollWidth: columns, - lines: { - updates: [{start: 0, end: 50, data}], - totalLength: 50 - } - } - ]); + [ + { + id: 'root', + y: 0, + width: columns, + height: rows, + lines: {updates: [], totalLength: 0}, + }, + { + id: 'scroll', + y: 0, + width: columns, + height: rows, + isScrollable: true, + overflowToBackbuffer: true, + scrollTop: 0, + scrollHeight: 50, + scrollWidth: columns, + lines: { + updates: [{start: 0, end: 50, data}], + totalLength: 50, + }, + }, + ], + ); await worker.render(); // Scroll to bottom - worker.update({ - id: 'root', - children: [{id: 'scroll', children: []}] - }, [ + worker.update( { - id: 'scroll', - scrollTop: 40 - } - ]); + id: 'root', + children: [{id: 'scroll', children: []}], + }, + [ + { + id: 'scroll', + scrollTop: 40, + }, + ], + ); await worker.render(); - const term = new Terminal({cols: columns, rows, allowProposedApi: true, convertEol: true}); - await new Promise((resolve) => term.write(output, resolve)); - + const term = new Terminal({ + cols: columns, + rows, + allowProposedApi: true, + convertEol: true, + }); + await new Promise(resolve => { + term.write(output, resolve); + }); // Expect the backbuffer to have lines 0 to 39 // And the screen to have lines 40 to 49 t.is(term.buffer.active.getLine(0)?.translateToString(true).trim(), 'Line 0'); - t.is(term.buffer.active.getLine(39)?.translateToString(true).trim(), 'Line 39'); - t.is(term.buffer.active.getLine(40)?.translateToString(true).trim(), 'Line 40'); + t.is( + term.buffer.active.getLine(39)?.translateToString(true).trim(), + 'Line 39', + ); + t.is( + term.buffer.active.getLine(40)?.translateToString(true).trim(), + 'Line 40', + ); }); diff --git a/test/repro-bug-scroll.test.ts b/test/repro-bug-scroll.test.ts index 05417145c..aa21e5f22 100644 --- a/test/repro-bug-scroll.test.ts +++ b/test/repro-bug-scroll.test.ts @@ -22,55 +22,72 @@ test('TerminalBufferWorker scroll down without backbuffer', async t => { const worker = new TerminalBufferWorker(columns, rows, {stdout}); const serializer = new Serializer(); - - const lines = Array.from({length: 20}, (_, i) => createStyledLine(`Line ${i}`)); + + const lines = Array.from({length: 20}, (_, i) => + createStyledLine(`Line ${i}`), + ); const data = serializer.serialize(lines); - worker.update({ - id: 'root', - children: [{id: 'scroll', children: []}] - }, [ + worker.update( { id: 'root', - y: 0, - width: columns, - height: rows, - lines: {updates: [], totalLength: 0} + children: [{id: 'scroll', children: []}], }, - { - id: 'scroll', - y: 0, - width: columns, - height: rows, - isScrollable: true, - overflowToBackbuffer: false, - scrollTop: 0, - scrollHeight: 20, - scrollWidth: columns, - lines: { - updates: [{start: 0, end: 20, data}], - totalLength: 20 - } - } - ]); + [ + { + id: 'root', + y: 0, + width: columns, + height: rows, + lines: {updates: [], totalLength: 0}, + }, + { + id: 'scroll', + y: 0, + width: columns, + height: rows, + isScrollable: true, + overflowToBackbuffer: false, + scrollTop: 0, + scrollHeight: 20, + scrollWidth: columns, + lines: { + updates: [{start: 0, end: 20, data}], + totalLength: 20, + }, + }, + ], + ); await worker.render(); - worker.update({ - id: 'root', - children: [{id: 'scroll', children: []}] - }, [ + worker.update( { - id: 'scroll', - scrollTop: 1 - } - ]); + id: 'root', + children: [{id: 'scroll', children: []}], + }, + [ + { + id: 'scroll', + scrollTop: 1, + }, + ], + ); await worker.render(); - const term = new Terminal({cols: columns, rows, allowProposedApi: true, convertEol: true}); - await new Promise((resolve) => term.write(output, resolve)); - + const term = new Terminal({ + cols: columns, + rows, + allowProposedApi: true, + convertEol: true, + }); + await new Promise(resolve => { + term.write(output, resolve); + }); // Expect the screen to have lines 1 to 10 - t.is(term.buffer.active.getLine(9)?.translateToString(true).trim(), 'Line 10'); + t.is( + term.buffer.active.getLine(9)?.translateToString(true).trim(), + 'Line 10', + ); }); diff --git a/test/snapshots/nested-static/simple-nested.svg b/test/snapshots/nested-static/simple-nested.svg new file mode 100644 index 000000000..108fd5df1 --- /dev/null +++ b/test/snapshots/nested-static/simple-nested.svg @@ -0,0 +1,10 @@ + + + + + Outer text + Inner text + + \ No newline at end of file diff --git a/test/snapshots/selection-snapshot/selection-initial.svg b/test/snapshots/selection-snapshot/selection-initial.svg index 053cbbaee..010e98515 100644 --- a/test/snapshots/selection-snapshot/selection-initial.svg +++ b/test/snapshots/selection-snapshot/selection-initial.svg @@ -4,163 +4,125 @@ - ┌────────────────────────────┐ + + + that is intended to wrap + - - Hello - - - World - - │┌──────────────────────────┐│ - ││ - - This is a - - test - ││ - │└──────────────────────────┘│ - - - Row - - - A - - Row - - - B - - - - This is a very long text - - - - that is intended to wrap - - - - because the container width - - - - is fixed to 20 characters. - - - - This is a very long text - - - - that is intended to wrap - - - - because the container width - - - - is fixed to 20 ch - - │ │ - │┌──────────────────────────┐│ - ││1 - - const - - - greeting - - = - ││ - ││ - - "Hello" - - ; - ││ - ││2 - - const - - - name - - = - - "World" - - ; - ││ - ││3 - - console - - . - - log - - ( - ││ - ││4 - - - greeting - - + - - ", " - - + - ││ - ││ - - name - - + - - " (wrap)" - ││ - ││5 - - ); - ││ - │└──────────────────────────┘│ - └────────────────────────────┘ - Cursor: 0, 24 - Full Text Length: 340 - Full Text JSON: "Hello World\nThis is a - test\nRow ARow B\nThis is a very long - text that is intended to wrap because - the container width is fixed to 20 - characters.\nThis is a very long text - that is intended to wrap because the - container width is fixed to 20 - ch\n\nconst greeting = \"Hello\";\nconst - name = \"World\";\nconsole.log(\n - greeting + \", \" + name + \" - (wrap)\"\n);" - anchorPoint: 0, Hello - focusPoint: 2, ); - ┌──────────────────────────────────────┐ - │Selected Text: │ - │Hello World │ - │This is a test │ - │Row ARow B │ - │This is a very long text that is │ - │intended to wrap because the container│ - │width is fixed to 20 characters. │ - │This is a very long text that is │ - │intended to wrap because the container│ - │width is fixed to 20 ch │ - │ │ - │const greeting = "Hello"; │ - │const name = "World"; │ - │console.log( │ - │ greeting + ", " + name + " (wrap)" │ - │); │ - └──────────────────────────────────────┘ + + because the container width + + + + is fixed to 20 ch + + │ │ + │┌──────────────────────────┐│ + ││1 + + const + + + greeting + + = + ││ + ││ + + "Hello" + + ; + ││ + ││2 + + const + + + name + + = + + "World" + + ; + ││ + ││3 + + console + + . + + log + + ( + ││ + ││4 + + + greeting + + + + + ", " + + + + ││ + ││ + + name + + + + + " (wrap)" + ││ + ││5 + + ); + ││ + │└──────────────────────────┘│ + └────────────────────────────┘ + Cursor: 0, 24 + Full Text Length: 340 + Full Text JSON: "Hello World\nThis is a + test\nRow ARow B\nThis is a very long + text that is intended to wrap because + the container width is fixed to 20 + characters.\nThis is a very long text + that is intended to wrap because the + container width is fixed to 20 + ch\n\nconst greeting = \"Hello\";\nconst + name = \"World\";\nconsole.log(\n + greeting + \", \" + name + \" + (wrap)\"\n);" + anchorPoint: 0, Hello + focusPoint: 2, ); + ┌──────────────────────────────────────┐ + │Selected Text: │ + │Hello World │ + │This is a test │ + │Row ARow B │ + │This is a very long text that is │ + │intended to wrap because the container│ + │width is fixed to 20 characters. │ + │This is a very long text that is │ + │intended to wrap because the container│ + │width is fixed to 20 ch │ + │ │ + │const greeting = "Hello"; │ + │const name = "World"; │ + │console.log( │ + │ greeting + ", " + name + " (wrap)" │ + │); │ + └──────────────────────────────────────┘ + Press 't' to toggle selection + visibility. + Press 'm' to toggle StaticRender + (current: OFF). + Press 'space' to toggle line number + selection. + Press 's' to select character under + cursor. + Use Arrow keys to move cursor. + Shift+Arrow to select. \ No newline at end of file diff --git a/test/snapshots/selection-snapshot/selection-static-render.svg b/test/snapshots/selection-snapshot/selection-static-render.svg index a674c587f..74c91f926 100644 --- a/test/snapshots/selection-snapshot/selection-static-render.svg +++ b/test/snapshots/selection-snapshot/selection-static-render.svg @@ -1,83 +1,94 @@ - + - + - ┌────────────────────────────┐ - - Hello - World - - │┌──────────────────────────┐│ - ││This is a - test - ││ + ││3 + + console + + . + + log + + ( + ││ + ││4 + + + greeting + + + + + ", " + + + + ││ + ││ + + name + + + + + " (wrap)" + ││ + ││5 + + ); + ││ │└──────────────────────────┘│ - - Row - A - Row - B - - │This is a very long text │ - │that is intended to wrap │ - │because the container width │ - │is fixed to 20 characters. │ - │This is a very long text │ - │that is intended to wrap │ - │because the container width │ - │is fixed to 20 ch │ - │ │ - │┌──────────────────────────┐│ - ││1 - const - greeting - = ││ - ││ - "Hello" - ; ││ - ││2 - const - name - = - "World" - ; ││ - ││3 - console - . - log - ( ││ - ││4 - greeting - + - ", " - + ││ - ││ - name - + - " (wrap)" - ││ - ││5 ); ││ - │└──────────────────────────┘│ - └────────────────────────────┘ - Cursor: 0, 0 - Full Text Length: 0 - Full Text JSON: "" - anchorPoint: undefined - focusPoint: undefined - ┌──────────────────────────────────────┐ - │Selected Text: │ - └──────────────────────────────────────┘ - Press 't' to toggle selection - visibility. - Press 'm' to toggle StaticRender - (current: ON). - Press 'space' to toggle line number - selection. - Press 's' to select character under - cursor. - Use Arrow keys to move cursor. - Shift+Arrow to select. + └────────────────────────────┘ + Cursor: 0, 24 + Full Text Length: 372 + Full Text JSON: "Hello World\n\n This is + a test\n\nRow ARow B\nThis is a very + long text\nthat is intended to + wrap\nbecause the container width\nis + fixed to 20 characters.\nThis is a very + long text\nthat is intended to + wrap\nbecause the container width\nis + fixed to 20 ch\n\n\n const greeting + =\n \"Hello\";\n const name = + \"World\";\n console.log(\n + greeting + \", \" +\n name + \" + (wrap)\"\n );" + anchorPoint: 0, ink-static-render + focusPoint: 372, ink-static-render + ┌──────────────────────────────────────┐ + │Selected Text: │ + │Hello World │ + │ │ + │ This is a test │ + │ │ + │Row ARow B │ + │This is a very long text │ + │that is intended to wrap │ + │because the container width │ + │is fixed to 20 characters. │ + │This is a very long text │ + │that is intended to wrap │ + │because the container width │ + │is fixed to 20 ch │ + │ │ + │ │ + │ const greeting = │ + │ "Hello"; │ + │ const name = "World"; │ + │ console.log( │ + │ greeting + ", " + │ + │ name + " (wrap)" │ + │ ); │ + └──────────────────────────────────────┘ + Press 't' to toggle selection + visibility. + Press 'm' to toggle StaticRender + (current: ON). + Press 'space' to toggle line number + selection. + Press 's' to select character under + cursor. + Use Arrow keys to move cursor. + Shift+Arrow to select. \ No newline at end of file diff --git a/test/snapshots/static-render-sticky-children.tsx.md b/test/snapshots/static-render-sticky-children.tsx.md index c5158bceb..9b33afedd 100644 --- a/test/snapshots/static-render-sticky-children.tsx.md +++ b/test/snapshots/static-render-sticky-children.tsx.md @@ -8,7 +8,7 @@ Generated by [AVA](https://avajs.dev). > initial (scrollTop: 0) - Header naturally at top - `Normal Header␊ + `NATURAL HEADER␊ Item 1␊ Item 2␊ Item 3␊ @@ -21,7 +21,7 @@ Generated by [AVA](https://avajs.dev). > stuck (scrollTop: 1) - Sticky header (taller) stuck to top - `Normal Header␊ + `NATURAL HEADER␊ Item 1␊ Item 2␊ Item 3␊ diff --git a/test/snapshots/static-render-sticky-children.tsx.snap b/test/snapshots/static-render-sticky-children.tsx.snap index dae30bdbdfede6662fb69105ba1710ea905f969e..3e3f8df6c4a49306e36eac38a44e4efba48b72b0 100644 GIT binary patch literal 282 zcmV+#0p+BpVzTRCFSH+mwWpO=QJ=eRJwtj+5Q$IAVc~p9b#ZA=KWXI(hF$>x0J%1WSO5S3 literal 275 zcmV+u0qp)kRzVE3Q#uD9e=a30^Y9mkU(&( z72#Xor5}q300000000B6kg-m~KoCS7A%t`tf3T%pASJj2flAP2-xD4c+{fQDhu4o1KV3NaKG4tP2j^@MnHC&2N5A Zp)ga_U^B@L&1s|w H2 stuck (scrollTop: 5) `Header 2␊ - Item 2-2␊ - Item 2-3 █` + Item 2-2 ▄␊ + Item 2-3 ▀` ## StaticRender with multi-line sticky header diff --git a/test/snapshots/static-render-sticky.tsx.snap b/test/snapshots/static-render-sticky.tsx.snap index 23a45db2b5d81685a7658ad1a92293af74bddfde..76aeadde79e68e27d2b94e4877455837cf08db53 100644 GIT binary patch literal 472 zcmV;}0Vn=JRzVs27;j{U~lih4LAuWL9HDT)4+rm$6u`X`@Z+&>0M8#XrNza3^@um+U_7l9$hgR zvlNCL`=F6WgE90OjakaIq>1h;)GxC*rbw^rYn6@4e66fkHdl61!fDq zGZ}-r1Hm;iL3>P$YFu*URKTu|QYA!JCC5;!f)Az+NQzlXg%|@xK$ZLlG}M?y0|!Y8 zNb?sJJaRTnRQhk+a|2}YZ9+9V{CbgZZ-L6tOn{??gr-a)Xci$?G9TBzGpDVIYNaM&bJ)>=zyIHcX2jkNELOWmY*#kwNSC3EMSG)#HCh^ z9mNyc?uyz)Pr$yt12^C#oCFm+5JO;9Z|uBS^SyZ^&pK_P!k!+_8FCbCu-!t6JiKNy zVhQv)c0nT#dqe0l8nJ|HNn_nrsK@gtqDXJ6Yo(3SVy&!~Hdl7)pVzzwvPU3h^UW4` z=Q0A-0l_sgLEB6WDqM2pRKTtd6D7oricg@j2W2pIKvGN-D#Q>d0;+f!&_H7v_8cV1 zA{)17Y4|p`IC9ccUc7OA=G_`5&X~avtnO|z=dW0{R02hy)5kg-*4kH=Ca_D{q; Date: Wed, 8 Apr 2026 16:50:25 -0700 Subject: [PATCH 3/3] docs: add note to GEMINI.md regarding max-params performance Add instruction to explicitly avoid destructured objects for resolving max-params linting errors in hot-path rendering code, instead preferring to ignore the linter rule due to GC allocation overhead. --- GEMINI.md | 1 + src/worker/canvas.ts | 46 ++++++-------- src/worker/compositor.ts | 132 +++++++++++++++------------------------ 3 files changed, 69 insertions(+), 110 deletions(-) diff --git a/GEMINI.md b/GEMINI.md index b7d6eed9d..cb3ac61a1 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -143,3 +143,4 @@ This module managed the actual output to the terminal `stdout` in the legacy ren - **``**: This component is considered a legacy feature. It is intended for permanently outputting text above the active Ink app (like a log). However, it is **not fully supported in alternate buffer mode** and will NEVER be supported by the new worker-based renderer. The architectural challenge is that `` relies on side-effects that can conflict with the strict timing requirements of `useLayoutEffect` used in the main rendering loop, potentially leading to out-of-order output or visual glitches in full-screen apps. - **``**: This is the more modern and efficient replacement for ``, designed to work better with the new rendering pipeline and avoid the pitfalls of the legacy implementation. This is the only static-style component supported by the new renderer. Use this instead of `` for new developments. +- **Performance vs Linting:** Avoid "fixing" `max-params` (or similar) warnings by refactoring function arguments into objects with named properties (e.g. `({a, b, c})`) on hot-path rendering code, as this adds unnecessary object allocation overhead per call. Ignore the linter warning using a disable comment (e.g. `// eslint-disable-next-line max-params`) instead. diff --git a/src/worker/canvas.ts b/src/worker/canvas.ts index 1d3cbcad8..2569a2d9b 100644 --- a/src/worker/canvas.ts +++ b/src/worker/canvas.ts @@ -68,25 +68,17 @@ export class Canvas { /** * Sets a character at the given coordinates, respecting clipping. */ - setChar({ - x, - y, - value, - formatFlags, - fgColor, - bgColor, - link, - clip, - }: { - x: number; - y: number; - value: string; - formatFlags: number; - fgColor?: string; - bgColor?: string; - link?: string; - clip?: Rect; - }) { + // eslint-disable-next-line max-params + setChar( + x: number, + y: number, + value: string, + formatFlags: number, + fgColor?: string, + bgColor?: string, + link?: string, + clip?: Rect, + ) { if (y < 0 || y >= this.height || x < 0 || x >= this.width) { return; } @@ -115,16 +107,16 @@ export class Canvas { */ drawStyledChars(x: number, y: number, chars: StyledLine, clip?: Rect) { for (let i = 0; i < chars.length; i++) { - this.setChar({ - x: x + i, + this.setChar( + x + i, y, - value: chars.getValue(i), - formatFlags: chars.getFormatFlags(i), - fgColor: chars.getFgColor(i), - bgColor: chars.getBgColor(i), - link: chars.getLink(i), + chars.getValue(i), + chars.getFormatFlags(i), + chars.getFgColor(i), + chars.getBgColor(i), + chars.getLink(i), clip, - }); + ); } } diff --git a/src/worker/compositor.ts b/src/worker/compositor.ts index 02a386ff2..eb88c267f 100644 --- a/src/worker/compositor.ts +++ b/src/worker/compositor.ts @@ -30,19 +30,13 @@ export class Compositor { constructor(private readonly options: CompositionOptions) {} - drawContent({ - canvas, - region, - absX, - absY, - clip, - }: { - canvas: Canvas; - region: Region; - absX: number; - absY: number; - clip: Rect; - }) { + drawContent( + canvas: Canvas, + region: Region, + absX: number, + absY: number, + clip: Rect, + ) { const scrollTop = region.scrollTop ?? 0; const scrollLeft = region.scrollLeft ?? 0; @@ -84,47 +78,41 @@ export class Compositor { if (region.backgroundColor) { const bgColor = this.getBackgroundStyles(region.backgroundColor); if (bgColor) { - canvas.setChar({ - x: sx, - y: sy, - value: val, - formatFlags: line.getFormatFlags(contentX), - fgColor: line.getFgColor(contentX), + canvas.setChar( + sx, + sy, + val, + line.getFormatFlags(contentX), + line.getFgColor(contentX), bgColor, - link: line.getLink(contentX), - }); + line.getLink(contentX), + ); continue; } } } - canvas.setChar({ - x: sx, - y: sy, - value: val, - formatFlags: line.getFormatFlags(contentX), - fgColor: line.getFgColor(contentX), - bgColor: line.getBgColor(contentX), - link: line.getLink(contentX), - }); + canvas.setChar( + sx, + sy, + val, + line.getFormatFlags(contentX), + line.getFgColor(contentX), + line.getBgColor(contentX), + line.getLink(contentX), + ); } } } } - drawStickyHeaders({ - canvas, - region, - absX, - absY, - clip, - }: { - canvas: Canvas; - region: Region; - absX: number; - absY: number; - clip: Rect; - }) { + drawStickyHeaders( + canvas: Canvas, + region: Region, + absX: number, + absY: number, + clip: Rect, + ) { if (this.options.skipStickyHeaders) { return; } @@ -229,34 +217,28 @@ export class Compositor { for (let sx = hx1; sx < hx2; sx++) { const cx = sx - headerX; if (cx < line.length) { - canvas.setChar({ - x: sx, - y: sy, - value: line.getValue(cx), - formatFlags: line.getFormatFlags(cx), - fgColor: line.getFgColor(cx), - bgColor: line.getBgColor(cx), - link: line.getLink(cx), - }); + canvas.setChar( + sx, + sy, + line.getValue(cx), + line.getFormatFlags(cx), + line.getFgColor(cx), + line.getBgColor(cx), + line.getLink(cx), + ); } } } } } - drawScrollbars({ - canvas, - region, - absX, - absY, - clip, - }: { - canvas: Canvas; - region: Region; - absX: number; - absY: number; - clip: Rect; - }) { + drawScrollbars( + canvas: Canvas, + region: Region, + absX: number, + absY: number, + clip: Rect, + ) { if ( Boolean(this.options.skipScrollbars && region.overflowToBackbuffer) || !region.isScrollable || @@ -322,15 +304,7 @@ export class Compositor { axis: 'vertical', color: region.scrollbarThumbColor, setChar(x, y, value, formatFlags, fgColor, bgColor, link) { - canvas.setChar({ - x, - y, - value, - formatFlags, - fgColor, - bgColor, - link, - }); + canvas.setChar(x, y, value, formatFlags, fgColor, bgColor, link); }, getExistingChar(x, y) { return canvas.getChar(x, y); @@ -374,15 +348,7 @@ export class Compositor { axis: 'horizontal', color: region.scrollbarThumbColor, setChar(x, y, value, formatFlags, fgColor, bgColor, link) { - canvas.setChar({ - x, - y, - value, - formatFlags, - fgColor, - bgColor, - link, - }); + canvas.setChar(x, y, value, formatFlags, fgColor, bgColor, link); }, getExistingChar(x, y) { return canvas.getChar(x, y);