UNPKG

ink

Version:
363 lines (361 loc) • 13.9 kB
import process from 'node:process'; import createReconciler from 'react-reconciler'; import { DefaultEventPriority, NoEventPriority, } from 'react-reconciler/constants.js'; import { createContext, version as reactVersion } from 'react'; import { createTextNode, appendChildNode, insertBeforeNode, removeChildNode, freeYogaSubtree, emitLayoutListeners, setTransform, setStyle, setTextNodeValue, setNodeHidden, createNode, setAttribute, } from './dom.js'; import applyStyles 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 // See https://github.com/vadimdemedes/ink/issues/648 if (process.env['DEV'] === 'true') { // Intentionally no warning when the package is missing. // DEV may be set for other reasons; devtools is opt-in via installing the package. let isDevtoolsInstalled = false; try { import.meta.resolve('react-devtools-core'); isDevtoolsInstalled = true; } catch { } if (isDevtoolsInstalled) { await import('./devtools.js'); } } const diff = (before, after) => { if (before === after) { return; } if (!before) { return after; } const changed = {}; let hasChanges = false; for (const key of Object.keys(before)) { const isDeleted = after ? !Object.hasOwn(after, key) : true; if (!isDeleted) { continue; } changed[key] = undefined; hasChanges = true; } if (after) { for (const [key, value] of Object.entries(after)) { if (value === before[key]) { continue; } changed[key] = value; hasChanges = true; } } return hasChanges ? changed : undefined; }; const findRootNode = (node) => { let current = node; while (current) { if (current.nodeName === 'ink-root') { return current; } current = current.parentNode; } return undefined; }; /** Clear the root's cached `staticNode` when the node it points at is being removed as part of a larger subtree. The previous identity check (`staticNode === removeNode`) only caught direct removal of the `<Static>` element. When an *ancestor* of `<Static>` is removed, the stale `staticNode` reference survives and the next render would replay stale static output (and, before `freeYogaSubtree`, trap on freed WASM memory, see QwenLM/qwen-code#6820). The owning root is derived from the host parent passed to the removal hook, not a module-level global, so instances with separate stdout streams don't clobber each other's pointers. */ const clearStaticNodeIfContained = (rootNode, removedNode) => { if (!rootNode?.staticNode) { return; } // Walk up from staticNode to see if removedNode is an ancestor. let current = rootNode.staticNode; while (current) { if (current === removedNode) { // Only clear staticNode, not previousStaticNode. The inequality // (undefined !== previousStaticNode) triggers onStaticChange in // resetAfterCommit, which resets the accumulated static output. rootNode.staticNode = undefined; return; } current = current.parentNode; } }; const findStaticNode = (node) => { if (node.internal_static) { return node; } for (const child of node.childNodes) { if (child.nodeName === '#text') { continue; } const staticNode = findStaticNode(child); if (staticNode) { return staticNode; } } return undefined; }; let currentUpdatePriority = NoEventPriority; async function loadPackageJson() { const fs = await import('node:fs'); const content = fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'); const parsedContent = JSON.parse(content); return { name: parsedContent?.name, version: parsedContent?.version, }; } let packageInfo = { name: 'ink', version: reactVersion, }; if (process.env['DEV'] === 'true') { try { const loaded = await loadPackageJson(); packageInfo = { name: loaded.name === undefined || loaded.name === '' ? packageInfo.name : loaded.name, version: loaded.version === undefined || loaded.version === '' ? packageInfo.version : loaded.version, }; } catch (error) { console.warn('Failed to load package.json in development mode. Falling back to default renderer metadata.', error); } } // eslint-disable-next-line @eslint-react/naming-convention-context-name -- This is the React reconciler host config, not a React context. const hostConfig = { getRootHostContext: () => ({ isInsideText: false, }), prepareForCommit: () => null, preparePortalMount: () => null, clearContainer: () => false, resetAfterCommit(rootNode) { // Save the <Static> reference from the committed tree so rendering can access it directly. createInstance also runs for abandoned transitions and must not replace the committed reference. const staticNode = findStaticNode(rootNode); rootNode.staticNode = staticNode; if (staticNode !== rootNode.previousStaticNode) { rootNode.isStaticDirty = true; } if (typeof rootNode.onComputeLayout === 'function') { rootNode.onComputeLayout(); } emitLayoutListeners(rootNode); /* Fire `onStaticChange` BEFORE `onImmediateRender` so ink resets accumulated static output before the new instance emits. Without this, items from a replaced/removed <Static> stay in `fullStaticOutput` (debug mode) and `hasRenderedStaticOutput` stays set, skipping the final unmount render for the new instance. */ if (rootNode.staticNode !== rootNode.previousStaticNode) { rootNode.previousStaticNode = rootNode.staticNode; if (typeof rootNode.onStaticChange === 'function') { rootNode.onStaticChange(); } } // Since renders are throttled at the instance level and <Static> component children // are rendered only once and then get deleted, we need an escape hatch to // trigger an immediate render to ensure <Static> children are written to output before they get erased if (rootNode.isStaticDirty) { rootNode.isStaticDirty = false; if (typeof rootNode.onImmediateRender === 'function') { rootNode.onImmediateRender(); } return; } if (typeof rootNode.onRender === 'function') { rootNode.onRender(); } }, getChildHostContext(parentHostContext, type) { const wasInsideText = parentHostContext.isInsideText; const isInsideText = type === 'ink-text' || type === 'ink-virtual-text'; return wasInsideText === isInsideText ? parentHostContext : { isInsideText }; }, shouldSetTextContent: () => false, createInstance(originalType, newProps, _rootNode, hostContext) { if (originalType === 'ink-box' && hostContext.isInsideText) { 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); for (const [key, value] of Object.entries(newProps)) { if (key === 'children') { continue; } if (key === 'style') { setStyle(node, value); if (node.yogaNode) { applyStyles(node.yogaNode, value); } continue; } if (key === 'internal_transform') { node.internal_transform = value; continue; } if (key === 'internal_static') { node.internal_static = true; continue; } setAttribute(node, key, value); } return node; }, createTextInstance(text, _root, hostContext) { 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 => instance, hideInstance(node) { setNodeHidden(node, true); }, unhideInstance(node) { setNodeHidden(node, false); }, appendInitialChild: appendChildNode, appendChild: appendChildNode, insertBefore: insertBeforeNode, finalizeInitialChildren() { return false; }, isPrimaryRenderer: true, supportsMutation: true, supportsPersistence: false, supportsHydration: false, // Scheduler integration for concurrent mode supportsMicrotasks: true, scheduleMicrotask: queueMicrotask, scheduleTimeout: setTimeout, cancelTimeout: clearTimeout, noTimeout: -1, beforeActiveInstanceBlur() { }, afterActiveInstanceBlur() { }, detachDeletedInstance() { }, getInstanceFromNode: () => null, // Fragment refs (React 19.3) have no meaning in a terminal. Returning null makes a `<Fragment ref>` resolve to null instead of crashing inside React, and keeps React from calling the other fragment-instance hooks. createFragmentInstance: () => null, prepareScopeUpdate() { }, getInstanceFromScope: () => null, appendChildToContainer: appendChildNode, insertInContainerBefore: insertBeforeNode, removeChildFromContainer(node, removedNode) { // `node` is the container, i.e. the root itself. Clear before // removeChildNode breaks the parent chain. clearStaticNodeIfContained(findRootNode(node), removedNode); removeChildNode(node, removedNode); freeYogaSubtree(removedNode); }, commitUpdate(node, _type, oldProps, newProps) { if (node.internal_static) { const rootNode = findRootNode(node); if (rootNode) { rootNode.isStaticDirty = true; } } const props = diff(oldProps, newProps); const style = diff(oldProps['style'], newProps['style']); if (!props && !style) { return; } if (props) { for (const [key, value] of Object.entries(props)) { if (key === 'style') { setStyle(node, value); continue; } if (key === 'internal_transform') { setTransform(node, value); continue; } if (key === 'internal_static') { node.internal_static = true; continue; } setAttribute(node, key, value); } } if (style && node.yogaNode) { applyStyles(node.yogaNode, style, newProps['style'] ?? {}); } }, commitTextUpdate(node, _oldText, newText) { setTextNodeValue(node, newText); }, removeChild(node, removedNode) { // `node` is the host parent; its chain up to the root is still intact // here, so derive the owning root from it rather than a global. clearStaticNodeIfContained(findRootNode(node), removedNode); removeChildNode(node, removedNode); freeYogaSubtree(removedNode); }, setCurrentUpdatePriority(newPriority) { currentUpdatePriority = newPriority; }, getCurrentUpdatePriority: () => currentUpdatePriority, resolveUpdatePriority() { return currentUpdatePriority === NoEventPriority ? DefaultEventPriority : currentUpdatePriority; }, maySuspendCommit() { // Return true to enable Suspense resource preloading return true; }, // Terminal host instances have no asynchronous resources to wait for on updates. maySuspendCommitOnUpdate: () => false, maySuspendCommitInSyncRender: () => false, // eslint-disable-next-line @typescript-eslint/naming-convention NotPendingTransition: undefined, // eslint-disable-next-line @typescript-eslint/naming-convention HostTransitionContext: createContext(null), resetFormInstance() { }, requestPostPaintCallback() { }, shouldAttemptEagerTransition() { return false; }, trackSchedulerEvent() { }, resolveEventType() { return null; }, resolveEventTimeStamp() { return -1.1; }, preloadInstance() { return true; }, startSuspendingCommit() { }, suspendInstance() { }, // Called for every transition-lane render since React 19.3. Ink has no view transitions, so there is never anything to wait for. suspendOnActiveViewTransition() { }, waitForCommitToBeReady() { return null; }, getSuspendedCommitReason: () => null, extraDevToolsConfig: null, bindToConsole(methodName, args) { // Replay React's captured console calls without browser-specific badge styling. const method = Reflect.get(console, methodName); return () => { if (typeof method === 'function') { Reflect.apply(method, console, args); } }; }, rendererPackageName: packageInfo.name, rendererVersion: packageInfo.version, }; export default createReconciler(hostConfig); //# sourceMappingURL=reconciler.js.map