source dump of claude code
at main 512 lines 15 kB view raw
1/* eslint-disable custom-rules/no-top-level-side-effects */ 2 3import { appendFileSync } from 'fs' 4import createReconciler from 'react-reconciler' 5import { getYogaCounters } from 'src/native-ts/yoga-layout/index.js' 6import { isEnvTruthy } from '../utils/envUtils.js' 7import { 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' 24import { Dispatcher } from './events/dispatcher.js' 25import { EVENT_HANDLER_PROPS } from './events/event-handlers.js' 26import { getFocusManager, getRootNode } from './focus.js' 27import { LayoutDisplay } from './layout/node.js' 28import 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 33if (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 ` 43The environment variable DEV is set to true, so Ink tried to import \`react-devtools-core\`, 44but this failed as it was not installed. Debugging with React Devtools requires it. 45 46To 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 60type AnyObject = Record<string, unknown> 61 62const 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 95const 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 108type Props = Record<string, unknown> 109 110type HostContext = { 111 isInsideText: boolean 112} 113 114function setEventHandler(node: DOMElement, key: string, value: unknown): void { 115 if (!node._eventHandlers) { 116 node._eventHandlers = {} 117 } 118 node._eventHandlers[key] = value 119} 120 121function 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. 152type FiberLike = { 153 elementType?: { displayName?: string; name?: string } | string | null 154 _debugOwner?: FiberLike | null 155 return?: FiberLike | null 156} 157 158export 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 179let debugRepaints: boolean | undefined 180export function isDebugRepaintsEnabled(): boolean { 181 if (debugRepaints === undefined) { 182 debugRepaints = isEnvTruthy(process.env.CLAUDE_CODE_DEBUG_REPAINTS) 183 } 184 return debugRepaints 185} 186 187export 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 191const COMMIT_LOG = process.env.CLAUDE_CODE_COMMIT_LOG 192let _commits = 0 193let _lastLog = 0 194let _lastCommitAt = 0 195let _maxGapMs = 0 196let _createCount = 0 197let _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. 202let _lastYogaMs = 0 203let _lastCommitMs = 0 204let _commitStart = 0 205export function recordYogaMs(ms: number): void { 206 _lastYogaMs = ms 207} 208export function getLastYogaMs(): number { 209 return _lastYogaMs 210} 211export function markCommitStart(): void { 212 _commitStart = performance.now() 213} 214export function getLastCommitMs(): number { 215 return _lastCommitMs 216} 217export function resetProfileCounters(): void { 218 _lastYogaMs = 0 219 _lastCommitMs = 0 220 _commitStart = 0 221} 222// --- END --- 223 224const 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. 510dispatcher.discreteUpdates = reconciler.discreteUpdates.bind(reconciler) 511 512export default reconciler