- 修复所有 33 个原始 tsc 编译错误(ink JSX 声明、类型不匹配、null check 等) - 清理 176 处 `: any` 类型标注,全部替换为具体推断类型 - 修复清理过程中引入的 41 个回归错误 - 最终结果:0 tsc 错误,0 个非注释 any 标注 - Build 验证通过(25.75MB bundle) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
513 lines
14 KiB
TypeScript
513 lines
14 KiB
TypeScript
/* 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: unknown) {
|
|
if (error instanceof Error && (error as NodeJS.ErrnoException).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
|