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/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-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 ddc0d9070..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); @@ -128,6 +171,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/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/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..4e0d91782 --- /dev/null +++ b/test/repro-bug-nested-static.test.ts @@ -0,0 +1,102 @@ +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..aa21e5f22 --- /dev/null +++ b/test/repro-bug-scroll.test.ts @@ -0,0 +1,93 @@ +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/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 dae30bdbd..3e3f8df6c 100644 Binary files a/test/snapshots/static-render-sticky-children.tsx.snap and b/test/snapshots/static-render-sticky-children.tsx.snap differ diff --git a/test/snapshots/static-render-sticky.tsx.md b/test/snapshots/static-render-sticky.tsx.md index d540a8d88..d915f34ec 100644 --- a/test/snapshots/static-render-sticky.tsx.md +++ b/test/snapshots/static-render-sticky.tsx.md @@ -47,8 +47,8 @@ Generated by [AVA](https://avajs.dev). > 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 23a45db2b..76aeadde7 100644 Binary files a/test/snapshots/static-render-sticky.tsx.snap and b/test/snapshots/static-render-sticky.tsx.snap differ 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},