/ ink / dom.ts
dom.ts
  1  import type { FocusManager } from './focus.js'
  2  import { createLayoutNode } from './layout/engine.js'
  3  import type { LayoutNode } from './layout/node.js'
  4  import { LayoutDisplay, LayoutMeasureMode } from './layout/node.js'
  5  import measureText from './measure-text.js'
  6  import { addPendingClear, nodeCache } from './node-cache.js'
  7  import squashTextNodes from './squash-text-nodes.js'
  8  import type { Styles, TextStyles } from './styles.js'
  9  import { expandTabs } from './tabstops.js'
 10  import wrapText from './wrap-text.js'
 11  
 12  type InkNode = {
 13    parentNode: DOMElement | undefined
 14    yogaNode?: LayoutNode
 15    style: Styles
 16  }
 17  
 18  export type TextName = '#text'
 19  export type ElementNames =
 20    | 'ink-root'
 21    | 'ink-box'
 22    | 'ink-text'
 23    | 'ink-virtual-text'
 24    | 'ink-link'
 25    | 'ink-progress'
 26    | 'ink-raw-ansi'
 27  
 28  export type NodeNames = ElementNames | TextName
 29  
 30  // eslint-disable-next-line @typescript-eslint/naming-convention
 31  export type DOMElement = {
 32    nodeName: ElementNames
 33    attributes: Record<string, DOMNodeAttribute>
 34    childNodes: DOMNode[]
 35    textStyles?: TextStyles
 36  
 37    // Internal properties
 38    onComputeLayout?: () => void
 39    onRender?: () => void
 40    onImmediateRender?: () => void
 41    // Used to skip empty renders during React 19's effect double-invoke in test mode
 42    hasRenderedContent?: boolean
 43  
 44    // When true, this node needs re-rendering
 45    dirty: boolean
 46    // Set by the reconciler's hideInstance/unhideInstance; survives style updates.
 47    isHidden?: boolean
 48    // Event handlers set by the reconciler for the capture/bubble dispatcher.
 49    // Stored separately from attributes so handler identity changes don't
 50    // mark dirty and defeat the blit optimization.
 51    _eventHandlers?: Record<string, unknown>
 52  
 53    // Scroll state for overflow: 'scroll' boxes. scrollTop is the number of
 54    // rows the content is scrolled down by. scrollHeight/scrollViewportHeight
 55    // are computed at render time and stored for imperative access. stickyScroll
 56    // auto-pins scrollTop to the bottom when content grows.
 57    scrollTop?: number
 58    // Accumulated scroll delta not yet applied to scrollTop. The renderer
 59    // drains this at SCROLL_MAX_PER_FRAME rows/frame so fast flicks show
 60    // intermediate frames instead of one big jump. Direction reversal
 61    // naturally cancels (pure accumulator, no target tracking).
 62    pendingScrollDelta?: number
 63    // Render-time clamp bounds for virtual scroll. useVirtualScroll writes
 64    // the currently-mounted children's coverage span; render-node-to-output
 65    // clamps scrollTop to stay within it. Prevents blank screen when
 66    // scrollTo's direct write races past React's async re-render — instead
 67    // of painting spacer (blank), the renderer holds at the edge of mounted
 68    // content until React catches up (next commit updates these bounds and
 69    // the clamp releases). Undefined = no clamp (sticky-scroll, cold start).
 70    scrollClampMin?: number
 71    scrollClampMax?: number
 72    scrollHeight?: number
 73    scrollViewportHeight?: number
 74    scrollViewportTop?: number
 75    stickyScroll?: boolean
 76    // Set by ScrollBox.scrollToElement; render-node-to-output reads
 77    // el.yogaNode.getComputedTop() (FRESH — same Yoga pass as scrollHeight)
 78    // and sets scrollTop = top + offset, then clears this. Unlike an
 79    // imperative scrollTo(N) which bakes in a number that's stale by the
 80    // time the throttled render fires, the element ref defers the position
 81    // read to paint time. One-shot.
 82    scrollAnchor?: { el: DOMElement; offset: number }
 83    // Only set on ink-root. The document owns focus — any node can
 84    // reach it by walking parentNode, like browser getRootNode().
 85    focusManager?: FocusManager
 86    // React component stack captured at createInstance time (reconciler.ts),
 87    // e.g. ['ToolUseLoader', 'Messages', 'REPL']. Only populated when
 88    // CLAUDE_CODE_DEBUG_REPAINTS is set. Used by findOwnerChainAtRow to
 89    // attribute scrollback-diff full-resets to the component that caused them.
 90    debugOwnerChain?: string[]
 91  } & InkNode
 92  
 93  export type TextNode = {
 94    nodeName: TextName
 95    nodeValue: string
 96  } & InkNode
 97  
 98  // eslint-disable-next-line @typescript-eslint/naming-convention
 99  export type DOMNode<T = { nodeName: NodeNames }> = T extends {
100    nodeName: infer U
101  }
102    ? U extends '#text'
103      ? TextNode
104      : DOMElement
105    : never
106  
107  // eslint-disable-next-line @typescript-eslint/naming-convention
108  export type DOMNodeAttribute = boolean | string | number
109  
110  export const createNode = (nodeName: ElementNames): DOMElement => {
111    const needsYogaNode =
112      nodeName !== 'ink-virtual-text' &&
113      nodeName !== 'ink-link' &&
114      nodeName !== 'ink-progress'
115    const node: DOMElement = {
116      nodeName,
117      style: {},
118      attributes: {},
119      childNodes: [],
120      parentNode: undefined,
121      yogaNode: needsYogaNode ? createLayoutNode() : undefined,
122      dirty: false,
123    }
124  
125    if (nodeName === 'ink-text') {
126      node.yogaNode?.setMeasureFunc(measureTextNode.bind(null, node))
127    } else if (nodeName === 'ink-raw-ansi') {
128      node.yogaNode?.setMeasureFunc(measureRawAnsiNode.bind(null, node))
129    }
130  
131    return node
132  }
133  
134  export const appendChildNode = (
135    node: DOMElement,
136    childNode: DOMElement,
137  ): void => {
138    if (childNode.parentNode) {
139      removeChildNode(childNode.parentNode, childNode)
140    }
141  
142    childNode.parentNode = node
143    node.childNodes.push(childNode)
144  
145    if (childNode.yogaNode) {
146      node.yogaNode?.insertChild(
147        childNode.yogaNode,
148        node.yogaNode.getChildCount(),
149      )
150    }
151  
152    markDirty(node)
153  }
154  
155  export const insertBeforeNode = (
156    node: DOMElement,
157    newChildNode: DOMNode,
158    beforeChildNode: DOMNode,
159  ): void => {
160    if (newChildNode.parentNode) {
161      removeChildNode(newChildNode.parentNode, newChildNode)
162    }
163  
164    newChildNode.parentNode = node
165  
166    const index = node.childNodes.indexOf(beforeChildNode)
167  
168    if (index >= 0) {
169      // Calculate yoga index BEFORE modifying childNodes.
170      // We can't use DOM index directly because some children (like ink-progress,
171      // ink-link, ink-virtual-text) don't have yogaNodes, so DOM indices don't
172      // match yoga indices.
173      let yogaIndex = 0
174      if (newChildNode.yogaNode && node.yogaNode) {
175        for (let i = 0; i < index; i++) {
176          if (node.childNodes[i]?.yogaNode) {
177            yogaIndex++
178          }
179        }
180      }
181  
182      node.childNodes.splice(index, 0, newChildNode)
183  
184      if (newChildNode.yogaNode && node.yogaNode) {
185        node.yogaNode.insertChild(newChildNode.yogaNode, yogaIndex)
186      }
187  
188      markDirty(node)
189      return
190    }
191  
192    node.childNodes.push(newChildNode)
193  
194    if (newChildNode.yogaNode) {
195      node.yogaNode?.insertChild(
196        newChildNode.yogaNode,
197        node.yogaNode.getChildCount(),
198      )
199    }
200  
201    markDirty(node)
202  }
203  
204  export const removeChildNode = (
205    node: DOMElement,
206    removeNode: DOMNode,
207  ): void => {
208    if (removeNode.yogaNode) {
209      removeNode.parentNode?.yogaNode?.removeChild(removeNode.yogaNode)
210    }
211  
212    // Collect cached rects from the removed subtree so they can be cleared
213    collectRemovedRects(node, removeNode)
214  
215    removeNode.parentNode = undefined
216  
217    const index = node.childNodes.indexOf(removeNode)
218    if (index >= 0) {
219      node.childNodes.splice(index, 1)
220    }
221  
222    markDirty(node)
223  }
224  
225  function collectRemovedRects(
226    parent: DOMElement,
227    removed: DOMNode,
228    underAbsolute = false,
229  ): void {
230    if (removed.nodeName === '#text') return
231    const elem = removed as DOMElement
232    // If this node or any ancestor in the removed subtree was absolute,
233    // its painted pixels may overlap non-siblings — flag for global blit
234    // disable. Normal-flow removals only affect direct siblings, which
235    // hasRemovedChild already handles.
236    const isAbsolute = underAbsolute || elem.style.position === 'absolute'
237    const cached = nodeCache.get(elem)
238    if (cached) {
239      addPendingClear(parent, cached, isAbsolute)
240      nodeCache.delete(elem)
241    }
242    for (const child of elem.childNodes) {
243      collectRemovedRects(parent, child, isAbsolute)
244    }
245  }
246  
247  export const setAttribute = (
248    node: DOMElement,
249    key: string,
250    value: DOMNodeAttribute,
251  ): void => {
252    // Skip 'children' - React handles children via appendChild/removeChild,
253    // not attributes. React always passes a new children reference, so
254    // tracking it as an attribute would mark everything dirty every render.
255    if (key === 'children') {
256      return
257    }
258    // Skip if unchanged
259    if (node.attributes[key] === value) {
260      return
261    }
262    node.attributes[key] = value
263    markDirty(node)
264  }
265  
266  export const setStyle = (node: DOMNode, style: Styles): void => {
267    // Compare style properties to avoid marking dirty unnecessarily.
268    // React creates new style objects on every render even when unchanged.
269    if (stylesEqual(node.style, style)) {
270      return
271    }
272    node.style = style
273    markDirty(node)
274  }
275  
276  export const setTextStyles = (
277    node: DOMElement,
278    textStyles: TextStyles,
279  ): void => {
280    // Same dirty-check guard as setStyle: React (and buildTextStyles in Text.tsx)
281    // allocate a new textStyles object on every render even when values are
282    // unchanged, so compare by value to avoid markDirty -> yoga re-measurement
283    // on every Text re-render.
284    if (shallowEqual(node.textStyles, textStyles)) {
285      return
286    }
287    node.textStyles = textStyles
288    markDirty(node)
289  }
290  
291  function stylesEqual(a: Styles, b: Styles): boolean {
292    return shallowEqual(a, b)
293  }
294  
295  function shallowEqual<T extends object>(
296    a: T | undefined,
297    b: T | undefined,
298  ): boolean {
299    // Fast path: same object reference (or both undefined)
300    if (a === b) return true
301    if (a === undefined || b === undefined) return false
302  
303    // Get all keys from both objects
304    const aKeys = Object.keys(a) as (keyof T)[]
305    const bKeys = Object.keys(b) as (keyof T)[]
306  
307    // Different number of properties
308    if (aKeys.length !== bKeys.length) return false
309  
310    // Compare each property
311    for (const key of aKeys) {
312      if (a[key] !== b[key]) return false
313    }
314  
315    return true
316  }
317  
318  export const createTextNode = (text: string): TextNode => {
319    const node: TextNode = {
320      nodeName: '#text',
321      nodeValue: text,
322      yogaNode: undefined,
323      parentNode: undefined,
324      style: {},
325    }
326  
327    setTextNodeValue(node, text)
328  
329    return node
330  }
331  
332  const measureTextNode = function (
333    node: DOMNode,
334    width: number,
335    widthMode: LayoutMeasureMode,
336  ): { width: number; height: number } {
337    const rawText =
338      node.nodeName === '#text' ? node.nodeValue : squashTextNodes(node)
339  
340    // Expand tabs for measurement (worst case: 8 spaces each).
341    // Actual tab expansion happens in output.ts based on screen position.
342    const text = expandTabs(rawText)
343  
344    const dimensions = measureText(text, width)
345  
346    // Text fits into container, no need to wrap
347    if (dimensions.width <= width) {
348      return dimensions
349    }
350  
351    // This is happening when <Box> is shrinking child nodes and layout asks
352    // if we can fit this text node in a <1px space, so we just say "no"
353    if (dimensions.width >= 1 && width > 0 && width < 1) {
354      return dimensions
355    }
356  
357    // For text with embedded newlines (pre-wrapped content), avoid re-wrapping
358    // at measurement width when layout is asking for intrinsic size (Undefined mode).
359    // This prevents height inflation during min/max size checks.
360    //
361    // However, when layout provides an actual constraint (Exactly or AtMost mode),
362    // we must respect it and measure at that width. Otherwise, if the actual
363    // rendering width is smaller than the natural width, the text will wrap to
364    // more lines than layout expects, causing content to be truncated.
365    if (text.includes('\n') && widthMode === LayoutMeasureMode.Undefined) {
366      const effectiveWidth = Math.max(width, dimensions.width)
367      return measureText(text, effectiveWidth)
368    }
369  
370    const textWrap = node.style?.textWrap ?? 'wrap'
371    const wrappedText = wrapText(text, width, textWrap)
372  
373    return measureText(wrappedText, width)
374  }
375  
376  // ink-raw-ansi nodes hold pre-rendered ANSI strings with known dimensions.
377  // No stringWidth, no wrapping, no tab expansion — the producer (e.g. ColorDiff)
378  // already wrapped to the target width and each line is exactly one terminal row.
379  const measureRawAnsiNode = function (node: DOMElement): {
380    width: number
381    height: number
382  } {
383    return {
384      width: node.attributes['rawWidth'] as number,
385      height: node.attributes['rawHeight'] as number,
386    }
387  }
388  
389  /**
390   * Mark a node and all its ancestors as dirty for re-rendering.
391   * Also marks yoga dirty for text remeasurement if this is a text node.
392   */
393  export const markDirty = (node?: DOMNode): void => {
394    let current: DOMNode | undefined = node
395    let markedYoga = false
396  
397    while (current) {
398      if (current.nodeName !== '#text') {
399        ;(current as DOMElement).dirty = true
400        // Only mark yoga dirty on leaf nodes that have measure functions
401        if (
402          !markedYoga &&
403          (current.nodeName === 'ink-text' ||
404            current.nodeName === 'ink-raw-ansi') &&
405          current.yogaNode
406        ) {
407          current.yogaNode.markDirty()
408          markedYoga = true
409        }
410      }
411      current = current.parentNode
412    }
413  }
414  
415  // Walk to root and call its onRender (the throttled scheduleRender). Use for
416  // DOM-level mutations (scrollTop changes) that should trigger an Ink frame
417  // without going through React's reconciler. Pair with markDirty() so the
418  // renderer knows which subtree to re-evaluate.
419  export const scheduleRenderFrom = (node?: DOMNode): void => {
420    let cur: DOMNode | undefined = node
421    while (cur?.parentNode) cur = cur.parentNode
422    if (cur && cur.nodeName !== '#text') (cur as DOMElement).onRender?.()
423  }
424  
425  export const setTextNodeValue = (node: TextNode, text: string): void => {
426    if (typeof text !== 'string') {
427      text = String(text)
428    }
429  
430    // Skip if unchanged
431    if (node.nodeValue === text) {
432      return
433    }
434  
435    node.nodeValue = text
436    markDirty(node)
437  }
438  
439  function isDOMElement(node: DOMElement | TextNode): node is DOMElement {
440    return node.nodeName !== '#text'
441  }
442  
443  // Clear yogaNode references recursively before freeing.
444  // freeRecursive() frees the node and ALL its children, so we must clear
445  // all yogaNode references to prevent dangling pointers.
446  export const clearYogaNodeReferences = (node: DOMElement | TextNode): void => {
447    if ('childNodes' in node) {
448      for (const child of node.childNodes) {
449        clearYogaNodeReferences(child)
450      }
451    }
452    node.yogaNode = undefined
453  }
454  
455  /**
456   * Find the React component stack responsible for content at screen row `y`.
457   *
458   * DFS the DOM tree accumulating yoga offsets. Returns the debugOwnerChain of
459   * the deepest node whose bounding box contains `y`. Called from ink.tsx when
460   * log-update triggers a full reset, to attribute the flicker to its source.
461   *
462   * Only useful when CLAUDE_CODE_DEBUG_REPAINTS is set (otherwise chains are
463   * undefined and this returns []).
464   */
465  export function findOwnerChainAtRow(root: DOMElement, y: number): string[] {
466    let best: string[] = []
467    walk(root, 0)
468    return best
469  
470    function walk(node: DOMElement, offsetY: number): void {
471      const yoga = node.yogaNode
472      if (!yoga || yoga.getDisplay() === LayoutDisplay.None) return
473  
474      const top = offsetY + yoga.getComputedTop()
475      const height = yoga.getComputedHeight()
476      if (y < top || y >= top + height) return
477  
478      if (node.debugOwnerChain) best = node.debugOwnerChain
479  
480      for (const child of node.childNodes) {
481        if (isDOMElement(child)) walk(child, top)
482      }
483    }
484  }