diff --git a/ui-tui/packages/hermes-ink/src/ink/dom.ts b/ui-tui/packages/hermes-ink/src/ink/dom.ts index 735ab0b0c..9ff1be411 100644 --- a/ui-tui/packages/hermes-ink/src/ink/dom.ts +++ b/ui-tui/packages/hermes-ink/src/ink/dom.ts @@ -83,6 +83,10 @@ export type DOMElement = { // Only set on ink-root. The document owns focus — any node can // reach it by walking parentNode, like browser getRootNode(). focusManager?: FocusManager + // Measurement cache for ink-text nodes: avoids re-squashing and re-wrapping + // text when yoga calls measureFunc multiple times per frame with different + // widths during flex re-pass. Keyed by `${width}|${widthMode}`. + _textMeasureCache?: { gen: number; entries: Map } } & InkNode export type TextNode = { @@ -311,10 +315,42 @@ export const createTextNode = (text: string): TextNode => { return node } +const MEASURE_CACHE_CAP = 16 + const measureTextNode = function ( node: DOMNode, width: number, widthMode: LayoutMeasureMode +): { width: number; height: number } { + const elem = node.nodeName !== '#text' ? (node as DOMElement) : node.parentNode + if (elem && elem.nodeName === 'ink-text') { + let cache = elem._textMeasureCache + if (!cache) { + cache = { gen: 0, entries: new Map() } + elem._textMeasureCache = cache + } + const key = `${width}|${widthMode}` + const hit = cache.entries.get(key) + if (hit && hit._gen === cache.gen) { + return hit.result + } + const result = computeTextMeasure(node, width, widthMode) + // Enforce cap with FIFO eviction to avoid unbounded growth during + // pathological frames where yoga probes many widths. + if (cache.entries.size >= MEASURE_CACHE_CAP) { + const firstKey = cache.entries.keys().next().value + cache.entries.delete(firstKey) + } + cache.entries.set(key, { _gen: cache.gen, result }) + return result + } + return computeTextMeasure(node, width, widthMode) +} + +const computeTextMeasure = function ( + node: DOMNode, + width: number, + widthMode: LayoutMeasureMode ): { width: number; height: number } { const rawText = node.nodeName === '#text' ? node.nodeValue : squashTextNodes(node) @@ -378,13 +414,19 @@ export const markDirty = (node?: DOMNode): void => { while (current) { if (current.nodeName !== '#text') { - ;(current as DOMElement).dirty = true + const elem = current as DOMElement + elem.dirty = true // Only mark yoga dirty on leaf nodes that have measure functions - if (!markedYoga && (current.nodeName === 'ink-text' || current.nodeName === 'ink-raw-ansi') && current.yogaNode) { - current.yogaNode.markDirty() + if (!markedYoga && (elem.nodeName === 'ink-text' || elem.nodeName === 'ink-raw-ansi') && elem.yogaNode) { + elem.yogaNode.markDirty() markedYoga = true } + + // Invalidate text measurement cache — child text or style changed. + if (elem._textMeasureCache) { + elem._textMeasureCache.gen++ + } } current = current.parentNode @@ -433,6 +475,7 @@ export const clearYogaNodeReferences = (node: DOMElement | TextNode): void => { for (const child of node.childNodes) { clearYogaNodeReferences(child) } + node._textMeasureCache = undefined } node.yogaNode = undefined