reconciler.ts
ink/reconciler.ts
513
Lines
14594
Bytes
8
Exports
10
Imports
10
Keywords
What this is
This page documents one file from the repository and includes its full source so you can read it without leaving the docs site.
Beginner explanation
This file is one piece of the larger system. Its name, directory, imports, and exports show where it fits. Start by reading the exports and related files first.
How it is used
Start from the exports list and related files. Those are the easiest clues for where this file fits into the system.
Expert explanation
Architecturally, this file intersects with ui-flow. It contains 513 lines, 10 detected imports, and 8 detected exports.
Important relationships
Detected exports
getOwnerChainisDebugRepaintsEnableddispatcherrecordYogaMsgetLastYogaMsmarkCommitStartgetLastCommitMsresetProfileCounters
Keywords
nodedomelementvoiddispatchercommit_logyoganodehostcontexttextunknownafter
Detected imports
fsreact-reconcilersrc/native-ts/yoga-layout/index.js../utils/envUtils.js./dom.js./events/dispatcher.js./events/event-handlers.js./focus.js./layout/node.js./styles.js
Source notes
This page embeds the full file contents. Small or leaf files are still indexed honestly instead of being over-explained.
Full source
/* eslint-disable custom-rules/no-top-level-side-effects */
import { appendFileSync } from 'fs'
import createReconciler from 'react-reconciler'
import { getYogaCounters } from 'src/native-ts/yoga-layout/index.js'
import { isEnvTruthy } from '../utils/envUtils.js'
import {
appendChildNode,
clearYogaNodeReferences,
createNode,
createTextNode,
type DOMElement,
type DOMNodeAttribute,
type ElementNames,
insertBeforeNode,
markDirty,
removeChildNode,
setAttribute,
setStyle,
setTextNodeValue,
setTextStyles,
type TextNode,
} from './dom.js'
import { Dispatcher } from './events/dispatcher.js'
import { EVENT_HANDLER_PROPS } from './events/event-handlers.js'
import { getFocusManager, getRootNode } from './focus.js'
import { LayoutDisplay } from './layout/node.js'
import applyStyles, { type Styles, type TextStyles } from './styles.js'
// We need to conditionally perform devtools connection to avoid
// accidentally breaking other third-party code.
// See https://github.com/vadimdemedes/ink/issues/384
if (process.env.NODE_ENV === 'development') {
try {
// eslint-disable-next-line custom-rules/no-top-level-dynamic-import -- dev-only; NODE_ENV check is DCE'd in production
void import('./devtools.js')
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
if (error.code === 'ERR_MODULE_NOT_FOUND') {
// biome-ignore lint/suspicious/noConsole: intentional warning
console.warn(
`
The environment variable DEV is set to true, so Ink tried to import \`react-devtools-core\`,
but this failed as it was not installed. Debugging with React Devtools requires it.
To install use this command:
$ npm install --save-dev react-devtools-core
`.trim() + '\n',
)
} else {
// eslint-disable-next-line @typescript-eslint/only-throw-error
throw error
}
}
}
// --
type AnyObject = Record<string, unknown>
const diff = (before: AnyObject, after: AnyObject): AnyObject | undefined => {
if (before === after) {
return
}
if (!before) {
return after
}
const changed: AnyObject = {}
let isChanged = false
for (const key of Object.keys(before)) {
const isDeleted = after ? !Object.hasOwn(after, key) : true
if (isDeleted) {
changed[key] = undefined
isChanged = true
}
}
if (after) {
for (const key of Object.keys(after)) {
if (after[key] !== before[key]) {
changed[key] = after[key]
isChanged = true
}
}
}
return isChanged ? changed : undefined
}
const cleanupYogaNode = (node: DOMElement | TextNode): void => {
const yogaNode = node.yogaNode
if (yogaNode) {
yogaNode.unsetMeasureFunc()
// Clear all references BEFORE freeing to prevent other code from
// accessing freed WASM memory during concurrent operations
clearYogaNodeReferences(node)
yogaNode.freeRecursive()
}
}
// --
type Props = Record<string, unknown>
type HostContext = {
isInsideText: boolean
}
function setEventHandler(node: DOMElement, key: string, value: unknown): void {
if (!node._eventHandlers) {
node._eventHandlers = {}
}
node._eventHandlers[key] = value
}
function applyProp(node: DOMElement, key: string, value: unknown): void {
if (key === 'children') return
if (key === 'style') {
setStyle(node, value as Styles)
if (node.yogaNode) {
applyStyles(node.yogaNode, value as Styles)
}
return
}
if (key === 'textStyles') {
node.textStyles = value as TextStyles
return
}
if (EVENT_HANDLER_PROPS.has(key)) {
setEventHandler(node, key, value)
return
}
setAttribute(node, key, value as DOMNodeAttribute)
}
// --
// react-reconciler's Fiber shape — only the fields we walk. The 5th arg to
// createInstance is the Fiber (`workInProgress` in react-reconciler.dev.js).
// _debugOwner is the component that rendered this element (dev builds only);
// return is the parent fiber (always present). We prefer _debugOwner since it
// skips past Box/Text wrappers to the actual named component.
type FiberLike = {
elementType?: { displayName?: string; name?: string } | string | null
_debugOwner?: FiberLike | null
return?: FiberLike | null
}
export function getOwnerChain(fiber: unknown): string[] {
const chain: string[] = []
const seen = new Set<unknown>()
let cur = fiber as FiberLike | null | undefined
for (let i = 0; cur && i < 50; i++) {
if (seen.has(cur)) break
seen.add(cur)
const t = cur.elementType
const name =
typeof t === 'function'
? (t as { displayName?: string; name?: string }).displayName ||
(t as { displayName?: string; name?: string }).name
: typeof t === 'string'
? undefined // host element (ink-box etc) — skip
: t?.displayName || t?.name
if (name && name !== chain[chain.length - 1]) chain.push(name)
cur = cur._debugOwner ?? cur.return
}
return chain
}
let debugRepaints: boolean | undefined
export function isDebugRepaintsEnabled(): boolean {
if (debugRepaints === undefined) {
debugRepaints = isEnvTruthy(process.env.CLAUDE_CODE_DEBUG_REPAINTS)
}
return debugRepaints
}
export const dispatcher = new Dispatcher()
// --- COMMIT INSTRUMENTATION (temp debugging) ---
// eslint-disable-next-line custom-rules/no-process-env-top-level -- debug instrumentation, read-once is fine
const COMMIT_LOG = process.env.CLAUDE_CODE_COMMIT_LOG
let _commits = 0
let _lastLog = 0
let _lastCommitAt = 0
let _maxGapMs = 0
let _createCount = 0
let _prepareAt = 0
// --- END ---
// --- SCROLL PROFILING (bench/scroll-e2e.sh reads via getLastYogaMs) ---
// Set by onComputeLayout wrapper in ink.tsx; read by onRender for phases.
let _lastYogaMs = 0
let _lastCommitMs = 0
let _commitStart = 0
export function recordYogaMs(ms: number): void {
_lastYogaMs = ms
}
export function getLastYogaMs(): number {
return _lastYogaMs
}
export function markCommitStart(): void {
_commitStart = performance.now()
}
export function getLastCommitMs(): number {
return _lastCommitMs
}
export function resetProfileCounters(): void {
_lastYogaMs = 0
_lastCommitMs = 0
_commitStart = 0
}
// --- END ---
const reconciler = createReconciler<
ElementNames,
Props,
DOMElement,
DOMElement,
TextNode,
DOMElement,
unknown,
unknown,
DOMElement,
HostContext,
null, // UpdatePayload - not used in React 19
NodeJS.Timeout,
-1,
null
>({
getRootHostContext: () => ({ isInsideText: false }),
prepareForCommit: () => {
if (COMMIT_LOG) _prepareAt = performance.now()
return null
},
preparePortalMount: () => null,
clearContainer: () => false,
resetAfterCommit(rootNode) {
_lastCommitMs = _commitStart > 0 ? performance.now() - _commitStart : 0
_commitStart = 0
if (COMMIT_LOG) {
const now = performance.now()
_commits++
const gap = _lastCommitAt > 0 ? now - _lastCommitAt : 0
if (gap > _maxGapMs) _maxGapMs = gap
_lastCommitAt = now
const reconcileMs = _prepareAt > 0 ? now - _prepareAt : 0
if (gap > 30 || reconcileMs > 20 || _createCount > 50) {
// eslint-disable-next-line custom-rules/no-sync-fs -- debug instrumentation
appendFileSync(
COMMIT_LOG,
`${now.toFixed(1)} gap=${gap.toFixed(1)}ms reconcile=${reconcileMs.toFixed(1)}ms creates=${_createCount}\n`,
)
}
_createCount = 0
if (now - _lastLog > 1000) {
// eslint-disable-next-line custom-rules/no-sync-fs -- debug instrumentation
appendFileSync(
COMMIT_LOG,
`${now.toFixed(1)} commits=${_commits}/s maxGap=${_maxGapMs.toFixed(1)}ms\n`,
)
_commits = 0
_maxGapMs = 0
_lastLog = now
}
}
const _t0 = COMMIT_LOG ? performance.now() : 0
if (typeof rootNode.onComputeLayout === 'function') {
rootNode.onComputeLayout()
}
if (COMMIT_LOG) {
const layoutMs = performance.now() - _t0
if (layoutMs > 20) {
const c = getYogaCounters()
// eslint-disable-next-line custom-rules/no-sync-fs -- debug instrumentation
appendFileSync(
COMMIT_LOG,
`${_t0.toFixed(1)} SLOW_YOGA ${layoutMs.toFixed(1)}ms visited=${c.visited} measured=${c.measured} hits=${c.cacheHits} live=${c.live}\n`,
)
}
}
if (process.env.NODE_ENV === 'test') {
if (rootNode.childNodes.length === 0 && rootNode.hasRenderedContent) {
return
}
if (rootNode.childNodes.length > 0) {
rootNode.hasRenderedContent = true
}
rootNode.onImmediateRender?.()
return
}
const _tr = COMMIT_LOG ? performance.now() : 0
rootNode.onRender?.()
if (COMMIT_LOG) {
const renderMs = performance.now() - _tr
if (renderMs > 10) {
// eslint-disable-next-line custom-rules/no-sync-fs -- debug instrumentation
appendFileSync(
COMMIT_LOG,
`${_tr.toFixed(1)} SLOW_PAINT ${renderMs.toFixed(1)}ms\n`,
)
}
}
},
getChildHostContext(
parentHostContext: HostContext,
type: ElementNames,
): HostContext {
const previousIsInsideText = parentHostContext.isInsideText
const isInsideText =
type === 'ink-text' || type === 'ink-virtual-text' || type === 'ink-link'
if (previousIsInsideText === isInsideText) {
return parentHostContext
}
return { isInsideText }
},
shouldSetTextContent: () => false,
createInstance(
originalType: ElementNames,
newProps: Props,
_root: DOMElement,
hostContext: HostContext,
internalHandle?: unknown,
): DOMElement {
if (hostContext.isInsideText && originalType === 'ink-box') {
throw new Error(`<Box> can't be nested inside <Text> component`)
}
const type =
originalType === 'ink-text' && hostContext.isInsideText
? 'ink-virtual-text'
: originalType
const node = createNode(type)
if (COMMIT_LOG) _createCount++
for (const [key, value] of Object.entries(newProps)) {
applyProp(node, key, value)
}
if (isDebugRepaintsEnabled()) {
node.debugOwnerChain = getOwnerChain(internalHandle)
}
return node
},
createTextInstance(
text: string,
_root: DOMElement,
hostContext: HostContext,
): TextNode {
if (!hostContext.isInsideText) {
throw new Error(
`Text string "${text}" must be rendered inside <Text> component`,
)
}
return createTextNode(text)
},
resetTextContent() {},
hideTextInstance(node) {
setTextNodeValue(node, '')
},
unhideTextInstance(node, text) {
setTextNodeValue(node, text)
},
getPublicInstance: (instance): DOMElement => instance as DOMElement,
hideInstance(node) {
node.isHidden = true
node.yogaNode?.setDisplay(LayoutDisplay.None)
markDirty(node)
},
unhideInstance(node) {
node.isHidden = false
node.yogaNode?.setDisplay(LayoutDisplay.Flex)
markDirty(node)
},
appendInitialChild: appendChildNode,
appendChild: appendChildNode,
insertBefore: insertBeforeNode,
finalizeInitialChildren(
_node: DOMElement,
_type: ElementNames,
props: Props,
): boolean {
return props['autoFocus'] === true
},
commitMount(node: DOMElement): void {
getFocusManager(node).handleAutoFocus(node)
},
isPrimaryRenderer: true,
supportsMutation: true,
supportsPersistence: false,
supportsHydration: false,
scheduleTimeout: setTimeout,
cancelTimeout: clearTimeout,
noTimeout: -1,
getCurrentUpdatePriority: () => dispatcher.currentUpdatePriority,
beforeActiveInstanceBlur() {},
afterActiveInstanceBlur() {},
detachDeletedInstance() {},
getInstanceFromNode: () => null,
prepareScopeUpdate() {},
getInstanceFromScope: () => null,
appendChildToContainer: appendChildNode,
insertInContainerBefore: insertBeforeNode,
removeChildFromContainer(node: DOMElement, removeNode: DOMElement): void {
removeChildNode(node, removeNode)
cleanupYogaNode(removeNode)
getFocusManager(node).handleNodeRemoved(removeNode, node)
},
// React 19 commitUpdate receives old and new props directly instead of an updatePayload
commitUpdate(
node: DOMElement,
_type: ElementNames,
oldProps: Props,
newProps: Props,
): void {
const props = diff(oldProps, newProps)
const style = diff(oldProps['style'] as Styles, newProps['style'] as Styles)
if (props) {
for (const [key, value] of Object.entries(props)) {
if (key === 'style') {
setStyle(node, value as Styles)
continue
}
if (key === 'textStyles') {
setTextStyles(node, value as TextStyles)
continue
}
if (EVENT_HANDLER_PROPS.has(key)) {
setEventHandler(node, key, value)
continue
}
setAttribute(node, key, value as DOMNodeAttribute)
}
}
if (style && node.yogaNode) {
applyStyles(node.yogaNode, style, newProps['style'] as Styles)
}
},
commitTextUpdate(node: TextNode, _oldText: string, newText: string): void {
setTextNodeValue(node, newText)
},
removeChild(node, removeNode) {
removeChildNode(node, removeNode)
cleanupYogaNode(removeNode)
if (removeNode.nodeName !== '#text') {
const root = getRootNode(node)
root.focusManager!.handleNodeRemoved(removeNode, root)
}
},
// React 19 required methods
maySuspendCommit(): boolean {
return false
},
preloadInstance(): boolean {
return true
},
startSuspendingCommit(): void {},
suspendInstance(): void {},
waitForCommitToBeReady(): null {
return null
},
NotPendingTransition: null,
HostTransitionContext: {
$$typeof: Symbol.for('react.context'),
_currentValue: null,
} as never,
setCurrentUpdatePriority(newPriority: number): void {
dispatcher.currentUpdatePriority = newPriority
},
resolveUpdatePriority(): number {
return dispatcher.resolveEventPriority()
},
resetFormInstance(): void {},
requestPostPaintCallback(): void {},
shouldAttemptEagerTransition(): boolean {
return false
},
trackSchedulerEvent(): void {},
resolveEventType(): string | null {
return dispatcher.currentEvent?.type ?? null
},
resolveEventTimeStamp(): number {
return dispatcher.currentEvent?.timeStamp ?? -1.1
},
})
// Wire the reconciler's discreteUpdates into the dispatcher.
// This breaks the import cycle: dispatcher.ts doesn't import reconciler.ts.
dispatcher.discreteUpdates = reconciler.discreteUpdates.bind(reconciler)
export default reconciler