Files
orcs-code/src/ink/reconciler.ts
gnanam1990 2c6ec0119e fix: prevent keyboard freeze when MCP notification effects fire
React 19 requires `supportsMicrotasks: true` in the reconciler host
config so it can flush state updates from passive effects via
queueMicrotask. Without this, state updates triggered inside
useMcpConnectivityStatus were silently dropped, corrupting React's
internal executionContext and causing all keyboard input to freeze
after the "N MCP server(s) need auth" notification appeared.

Root cause (three-part fix):

1. reconciler.ts: declare supportsMicrotasks + scheduleMicrotask so
   React 19 schedules passive-effect flushes correctly.

2. useMcpConnectivityStatus.tsx: wrap the MCP auth notification effect
   in try/catch so any unexpected throw does not propagate into
   flushPassiveEffects and permanently corrupt executionContext.

3. notifications.tsx: wrap addNotification, removeNotification, and
   processQueue in try/catch for the same reason — these are called
   from 12+ notification hooks across passive effects.

Also fixes a pre-existing test isolation bug in context.test.ts where
assigning `undefined` to process.env produced the string "undefined",
polluting the env for subsequent test files.

Resolves: #169, #205, #77
2026-04-03 07:41:53 +05:30

543 lines
15 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: 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>
type UpdatePayload = {
props?: AnyObject
style?: AnyObject
nextStyle?: Styles | undefined
}
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,
UpdatePayload | null,
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
},
prepareUpdate(
_node: DOMElement,
_type: ElementNames,
oldProps: Props,
newProps: Props,
): UpdatePayload | null {
const props = diff(oldProps, newProps)
const style = diff(oldProps['style'] as Styles, newProps['style'] as Styles)
if (!props && !style) {
return null
}
return {
props,
style,
nextStyle: newProps['style'] as Styles | undefined,
}
},
commitMount(node: DOMElement): void {
getFocusManager(node).handleAutoFocus(node)
},
isPrimaryRenderer: true,
supportsMutation: true,
supportsPersistence: false,
supportsHydration: false,
scheduleTimeout: setTimeout,
cancelTimeout: clearTimeout,
noTimeout: -1,
supportsMicrotasks: true,
scheduleMicrotask: queueMicrotask,
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)
},
commitUpdate(
node: DOMElement,
updatePayload: UpdatePayload | null,
_type: ElementNames,
_oldProps: Props,
_newProps: Props,
): void {
if (!updatePayload) {
return
}
const { props, style, nextStyle } = updatePayload
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, nextStyle)
}
},
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