/ ink / reconciler.ts
reconciler.ts
  1  /* eslint-disable custom-rules/no-top-level-side-effects */
  2  
  3  import { appendFileSync } from 'fs'
  4  import createReconciler from 'react-reconciler'
  5  import { getYogaCounters } from 'src/native-ts/yoga-layout/index.js'
  6  import { isEnvTruthy } from '../utils/envUtils.js'
  7  import {
  8    appendChildNode,
  9    clearYogaNodeReferences,
 10    createNode,
 11    createTextNode,
 12    type DOMElement,
 13    type DOMNodeAttribute,
 14    type ElementNames,
 15    insertBeforeNode,
 16    markDirty,
 17    removeChildNode,
 18    setAttribute,
 19    setStyle,
 20    setTextNodeValue,
 21    setTextStyles,
 22    type TextNode,
 23  } from './dom.js'
 24  import { Dispatcher } from './events/dispatcher.js'
 25  import { EVENT_HANDLER_PROPS } from './events/event-handlers.js'
 26  import { getFocusManager, getRootNode } from './focus.js'
 27  import { LayoutDisplay } from './layout/node.js'
 28  import applyStyles, { type Styles, type TextStyles } from './styles.js'
 29  
 30  // We need to conditionally perform devtools connection to avoid
 31  // accidentally breaking other third-party code.
 32  // See https://github.com/vadimdemedes/ink/issues/384
 33  if (process.env.NODE_ENV === 'development') {
 34    try {
 35      // eslint-disable-next-line custom-rules/no-top-level-dynamic-import -- dev-only; NODE_ENV check is DCE'd in production
 36      void import('./devtools.js')
 37      // eslint-disable-next-line @typescript-eslint/no-explicit-any
 38    } catch (error: any) {
 39      if (error.code === 'ERR_MODULE_NOT_FOUND') {
 40        // biome-ignore lint/suspicious/noConsole: intentional warning
 41        console.warn(
 42          `
 43  The environment variable DEV is set to true, so Ink tried to import \`react-devtools-core\`,
 44  but this failed as it was not installed. Debugging with React Devtools requires it.
 45  
 46  To install use this command:
 47  
 48  $ npm install --save-dev react-devtools-core
 49  				`.trim() + '\n',
 50        )
 51      } else {
 52        // eslint-disable-next-line @typescript-eslint/only-throw-error
 53        throw error
 54      }
 55    }
 56  }
 57  
 58  // --
 59  
 60  type AnyObject = Record<string, unknown>
 61  
 62  const diff = (before: AnyObject, after: AnyObject): AnyObject | undefined => {
 63    if (before === after) {
 64      return
 65    }
 66  
 67    if (!before) {
 68      return after
 69    }
 70  
 71    const changed: AnyObject = {}
 72    let isChanged = false
 73  
 74    for (const key of Object.keys(before)) {
 75      const isDeleted = after ? !Object.hasOwn(after, key) : true
 76  
 77      if (isDeleted) {
 78        changed[key] = undefined
 79        isChanged = true
 80      }
 81    }
 82  
 83    if (after) {
 84      for (const key of Object.keys(after)) {
 85        if (after[key] !== before[key]) {
 86          changed[key] = after[key]
 87          isChanged = true
 88        }
 89      }
 90    }
 91  
 92    return isChanged ? changed : undefined
 93  }
 94  
 95  const cleanupYogaNode = (node: DOMElement | TextNode): void => {
 96    const yogaNode = node.yogaNode
 97    if (yogaNode) {
 98      yogaNode.unsetMeasureFunc()
 99      // Clear all references BEFORE freeing to prevent other code from
100      // accessing freed WASM memory during concurrent operations
101      clearYogaNodeReferences(node)
102      yogaNode.freeRecursive()
103    }
104  }
105  
106  // --
107  
108  type Props = Record<string, unknown>
109  
110  type HostContext = {
111    isInsideText: boolean
112  }
113  
114  function setEventHandler(node: DOMElement, key: string, value: unknown): void {
115    if (!node._eventHandlers) {
116      node._eventHandlers = {}
117    }
118    node._eventHandlers[key] = value
119  }
120  
121  function applyProp(node: DOMElement, key: string, value: unknown): void {
122    if (key === 'children') return
123  
124    if (key === 'style') {
125      setStyle(node, value as Styles)
126      if (node.yogaNode) {
127        applyStyles(node.yogaNode, value as Styles)
128      }
129      return
130    }
131  
132    if (key === 'textStyles') {
133      node.textStyles = value as TextStyles
134      return
135    }
136  
137    if (EVENT_HANDLER_PROPS.has(key)) {
138      setEventHandler(node, key, value)
139      return
140    }
141  
142    setAttribute(node, key, value as DOMNodeAttribute)
143  }
144  
145  // --
146  
147  // react-reconciler's Fiber shape — only the fields we walk. The 5th arg to
148  // createInstance is the Fiber (`workInProgress` in react-reconciler.dev.js).
149  // _debugOwner is the component that rendered this element (dev builds only);
150  // return is the parent fiber (always present). We prefer _debugOwner since it
151  // skips past Box/Text wrappers to the actual named component.
152  type FiberLike = {
153    elementType?: { displayName?: string; name?: string } | string | null
154    _debugOwner?: FiberLike | null
155    return?: FiberLike | null
156  }
157  
158  export function getOwnerChain(fiber: unknown): string[] {
159    const chain: string[] = []
160    const seen = new Set<unknown>()
161    let cur = fiber as FiberLike | null | undefined
162    for (let i = 0; cur && i < 50; i++) {
163      if (seen.has(cur)) break
164      seen.add(cur)
165      const t = cur.elementType
166      const name =
167        typeof t === 'function'
168          ? (t as { displayName?: string; name?: string }).displayName ||
169            (t as { displayName?: string; name?: string }).name
170          : typeof t === 'string'
171            ? undefined // host element (ink-box etc) — skip
172            : t?.displayName || t?.name
173      if (name && name !== chain[chain.length - 1]) chain.push(name)
174      cur = cur._debugOwner ?? cur.return
175    }
176    return chain
177  }
178  
179  let debugRepaints: boolean | undefined
180  export function isDebugRepaintsEnabled(): boolean {
181    if (debugRepaints === undefined) {
182      debugRepaints = isEnvTruthy(process.env.CLAUDE_CODE_DEBUG_REPAINTS)
183    }
184    return debugRepaints
185  }
186  
187  export const dispatcher = new Dispatcher()
188  
189  // --- COMMIT INSTRUMENTATION (temp debugging) ---
190  // eslint-disable-next-line custom-rules/no-process-env-top-level -- debug instrumentation, read-once is fine
191  const COMMIT_LOG = process.env.CLAUDE_CODE_COMMIT_LOG
192  let _commits = 0
193  let _lastLog = 0
194  let _lastCommitAt = 0
195  let _maxGapMs = 0
196  let _createCount = 0
197  let _prepareAt = 0
198  // --- END ---
199  
200  // --- SCROLL PROFILING (bench/scroll-e2e.sh reads via getLastYogaMs) ---
201  // Set by onComputeLayout wrapper in ink.tsx; read by onRender for phases.
202  let _lastYogaMs = 0
203  let _lastCommitMs = 0
204  let _commitStart = 0
205  export function recordYogaMs(ms: number): void {
206    _lastYogaMs = ms
207  }
208  export function getLastYogaMs(): number {
209    return _lastYogaMs
210  }
211  export function markCommitStart(): void {
212    _commitStart = performance.now()
213  }
214  export function getLastCommitMs(): number {
215    return _lastCommitMs
216  }
217  export function resetProfileCounters(): void {
218    _lastYogaMs = 0
219    _lastCommitMs = 0
220    _commitStart = 0
221  }
222  // --- END ---
223  
224  const reconciler = createReconciler<
225    ElementNames,
226    Props,
227    DOMElement,
228    DOMElement,
229    TextNode,
230    DOMElement,
231    unknown,
232    unknown,
233    DOMElement,
234    HostContext,
235    null, // UpdatePayload - not used in React 19
236    NodeJS.Timeout,
237    -1,
238    null
239  >({
240    getRootHostContext: () => ({ isInsideText: false }),
241    prepareForCommit: () => {
242      if (COMMIT_LOG) _prepareAt = performance.now()
243      return null
244    },
245    preparePortalMount: () => null,
246    clearContainer: () => false,
247    resetAfterCommit(rootNode) {
248      _lastCommitMs = _commitStart > 0 ? performance.now() - _commitStart : 0
249      _commitStart = 0
250      if (COMMIT_LOG) {
251        const now = performance.now()
252        _commits++
253        const gap = _lastCommitAt > 0 ? now - _lastCommitAt : 0
254        if (gap > _maxGapMs) _maxGapMs = gap
255        _lastCommitAt = now
256        const reconcileMs = _prepareAt > 0 ? now - _prepareAt : 0
257        if (gap > 30 || reconcileMs > 20 || _createCount > 50) {
258          // eslint-disable-next-line custom-rules/no-sync-fs -- debug instrumentation
259          appendFileSync(
260            COMMIT_LOG,
261            `${now.toFixed(1)} gap=${gap.toFixed(1)}ms reconcile=${reconcileMs.toFixed(1)}ms creates=${_createCount}\n`,
262          )
263        }
264        _createCount = 0
265        if (now - _lastLog > 1000) {
266          // eslint-disable-next-line custom-rules/no-sync-fs -- debug instrumentation
267          appendFileSync(
268            COMMIT_LOG,
269            `${now.toFixed(1)} commits=${_commits}/s maxGap=${_maxGapMs.toFixed(1)}ms\n`,
270          )
271          _commits = 0
272          _maxGapMs = 0
273          _lastLog = now
274        }
275      }
276      const _t0 = COMMIT_LOG ? performance.now() : 0
277      if (typeof rootNode.onComputeLayout === 'function') {
278        rootNode.onComputeLayout()
279      }
280      if (COMMIT_LOG) {
281        const layoutMs = performance.now() - _t0
282        if (layoutMs > 20) {
283          const c = getYogaCounters()
284          // eslint-disable-next-line custom-rules/no-sync-fs -- debug instrumentation
285          appendFileSync(
286            COMMIT_LOG,
287            `${_t0.toFixed(1)} SLOW_YOGA ${layoutMs.toFixed(1)}ms visited=${c.visited} measured=${c.measured} hits=${c.cacheHits} live=${c.live}\n`,
288          )
289        }
290      }
291  
292      if (process.env.NODE_ENV === 'test') {
293        if (rootNode.childNodes.length === 0 && rootNode.hasRenderedContent) {
294          return
295        }
296        if (rootNode.childNodes.length > 0) {
297          rootNode.hasRenderedContent = true
298        }
299        rootNode.onImmediateRender?.()
300        return
301      }
302  
303      const _tr = COMMIT_LOG ? performance.now() : 0
304      rootNode.onRender?.()
305      if (COMMIT_LOG) {
306        const renderMs = performance.now() - _tr
307        if (renderMs > 10) {
308          // eslint-disable-next-line custom-rules/no-sync-fs -- debug instrumentation
309          appendFileSync(
310            COMMIT_LOG,
311            `${_tr.toFixed(1)} SLOW_PAINT ${renderMs.toFixed(1)}ms\n`,
312          )
313        }
314      }
315    },
316    getChildHostContext(
317      parentHostContext: HostContext,
318      type: ElementNames,
319    ): HostContext {
320      const previousIsInsideText = parentHostContext.isInsideText
321      const isInsideText =
322        type === 'ink-text' || type === 'ink-virtual-text' || type === 'ink-link'
323  
324      if (previousIsInsideText === isInsideText) {
325        return parentHostContext
326      }
327  
328      return { isInsideText }
329    },
330    shouldSetTextContent: () => false,
331    createInstance(
332      originalType: ElementNames,
333      newProps: Props,
334      _root: DOMElement,
335      hostContext: HostContext,
336      internalHandle?: unknown,
337    ): DOMElement {
338      if (hostContext.isInsideText && originalType === 'ink-box') {
339        throw new Error(`<Box> can't be nested inside <Text> component`)
340      }
341  
342      const type =
343        originalType === 'ink-text' && hostContext.isInsideText
344          ? 'ink-virtual-text'
345          : originalType
346  
347      const node = createNode(type)
348      if (COMMIT_LOG) _createCount++
349  
350      for (const [key, value] of Object.entries(newProps)) {
351        applyProp(node, key, value)
352      }
353  
354      if (isDebugRepaintsEnabled()) {
355        node.debugOwnerChain = getOwnerChain(internalHandle)
356      }
357  
358      return node
359    },
360    createTextInstance(
361      text: string,
362      _root: DOMElement,
363      hostContext: HostContext,
364    ): TextNode {
365      if (!hostContext.isInsideText) {
366        throw new Error(
367          `Text string "${text}" must be rendered inside <Text> component`,
368        )
369      }
370  
371      return createTextNode(text)
372    },
373    resetTextContent() {},
374    hideTextInstance(node) {
375      setTextNodeValue(node, '')
376    },
377    unhideTextInstance(node, text) {
378      setTextNodeValue(node, text)
379    },
380    getPublicInstance: (instance): DOMElement => instance as DOMElement,
381    hideInstance(node) {
382      node.isHidden = true
383      node.yogaNode?.setDisplay(LayoutDisplay.None)
384      markDirty(node)
385    },
386    unhideInstance(node) {
387      node.isHidden = false
388      node.yogaNode?.setDisplay(LayoutDisplay.Flex)
389      markDirty(node)
390    },
391    appendInitialChild: appendChildNode,
392    appendChild: appendChildNode,
393    insertBefore: insertBeforeNode,
394    finalizeInitialChildren(
395      _node: DOMElement,
396      _type: ElementNames,
397      props: Props,
398    ): boolean {
399      return props['autoFocus'] === true
400    },
401    commitMount(node: DOMElement): void {
402      getFocusManager(node).handleAutoFocus(node)
403    },
404    isPrimaryRenderer: true,
405    supportsMutation: true,
406    supportsPersistence: false,
407    supportsHydration: false,
408    scheduleTimeout: setTimeout,
409    cancelTimeout: clearTimeout,
410    noTimeout: -1,
411    getCurrentUpdatePriority: () => dispatcher.currentUpdatePriority,
412    beforeActiveInstanceBlur() {},
413    afterActiveInstanceBlur() {},
414    detachDeletedInstance() {},
415    getInstanceFromNode: () => null,
416    prepareScopeUpdate() {},
417    getInstanceFromScope: () => null,
418    appendChildToContainer: appendChildNode,
419    insertInContainerBefore: insertBeforeNode,
420    removeChildFromContainer(node: DOMElement, removeNode: DOMElement): void {
421      removeChildNode(node, removeNode)
422      cleanupYogaNode(removeNode)
423      getFocusManager(node).handleNodeRemoved(removeNode, node)
424    },
425    // React 19 commitUpdate receives old and new props directly instead of an updatePayload
426    commitUpdate(
427      node: DOMElement,
428      _type: ElementNames,
429      oldProps: Props,
430      newProps: Props,
431    ): void {
432      const props = diff(oldProps, newProps)
433      const style = diff(oldProps['style'] as Styles, newProps['style'] as Styles)
434  
435      if (props) {
436        for (const [key, value] of Object.entries(props)) {
437          if (key === 'style') {
438            setStyle(node, value as Styles)
439            continue
440          }
441  
442          if (key === 'textStyles') {
443            setTextStyles(node, value as TextStyles)
444            continue
445          }
446  
447          if (EVENT_HANDLER_PROPS.has(key)) {
448            setEventHandler(node, key, value)
449            continue
450          }
451  
452          setAttribute(node, key, value as DOMNodeAttribute)
453        }
454      }
455  
456      if (style && node.yogaNode) {
457        applyStyles(node.yogaNode, style, newProps['style'] as Styles)
458      }
459    },
460    commitTextUpdate(node: TextNode, _oldText: string, newText: string): void {
461      setTextNodeValue(node, newText)
462    },
463    removeChild(node, removeNode) {
464      removeChildNode(node, removeNode)
465      cleanupYogaNode(removeNode)
466      if (removeNode.nodeName !== '#text') {
467        const root = getRootNode(node)
468        root.focusManager!.handleNodeRemoved(removeNode, root)
469      }
470    },
471    // React 19 required methods
472    maySuspendCommit(): boolean {
473      return false
474    },
475    preloadInstance(): boolean {
476      return true
477    },
478    startSuspendingCommit(): void {},
479    suspendInstance(): void {},
480    waitForCommitToBeReady(): null {
481      return null
482    },
483    NotPendingTransition: null,
484    HostTransitionContext: {
485      $$typeof: Symbol.for('react.context'),
486      _currentValue: null,
487    } as never,
488    setCurrentUpdatePriority(newPriority: number): void {
489      dispatcher.currentUpdatePriority = newPriority
490    },
491    resolveUpdatePriority(): number {
492      return dispatcher.resolveEventPriority()
493    },
494    resetFormInstance(): void {},
495    requestPostPaintCallback(): void {},
496    shouldAttemptEagerTransition(): boolean {
497      return false
498    },
499    trackSchedulerEvent(): void {},
500    resolveEventType(): string | null {
501      return dispatcher.currentEvent?.type ?? null
502    },
503    resolveEventTimeStamp(): number {
504      return dispatcher.currentEvent?.timeStamp ?? -1.1
505    },
506  })
507  
508  // Wire the reconciler's discreteUpdates into the dispatcher.
509  // This breaks the import cycle: dispatcher.ts doesn't import reconciler.ts.
510  dispatcher.discreteUpdates = reconciler.discreteUpdates.bind(reconciler)
511  
512  export default reconciler