/ ink / root.ts
root.ts
  1  import type { ReactNode } from 'react'
  2  import { logForDebugging } from 'src/utils/debug.js'
  3  import { Stream } from 'stream'
  4  import type { FrameEvent } from './frame.js'
  5  import Ink, { type Options as InkOptions } from './ink.js'
  6  import instances from './instances.js'
  7  
  8  export type RenderOptions = {
  9    /**
 10     * Output stream where app will be rendered.
 11     *
 12     * @default process.stdout
 13     */
 14    stdout?: NodeJS.WriteStream
 15    /**
 16     * Input stream where app will listen for input.
 17     *
 18     * @default process.stdin
 19     */
 20    stdin?: NodeJS.ReadStream
 21    /**
 22     * Error stream.
 23     * @default process.stderr
 24     */
 25    stderr?: NodeJS.WriteStream
 26    /**
 27     * Configure whether Ink should listen to Ctrl+C keyboard input and exit the app. This is needed in case `process.stdin` is in raw mode, because then Ctrl+C is ignored by default and process is expected to handle it manually.
 28     *
 29     * @default true
 30     */
 31    exitOnCtrlC?: boolean
 32  
 33    /**
 34     * Patch console methods to ensure console output doesn't mix with Ink output.
 35     *
 36     * @default true
 37     */
 38    patchConsole?: boolean
 39  
 40    /**
 41     * Called after each frame render with timing and flicker information.
 42     */
 43    onFrame?: (event: FrameEvent) => void
 44  }
 45  
 46  export type Instance = {
 47    /**
 48     * Replace previous root node with a new one or update props of the current root node.
 49     */
 50    rerender: Ink['render']
 51    /**
 52     * Manually unmount the whole Ink app.
 53     */
 54    unmount: Ink['unmount']
 55    /**
 56     * Returns a promise, which resolves when app is unmounted.
 57     */
 58    waitUntilExit: Ink['waitUntilExit']
 59    cleanup: () => void
 60  }
 61  
 62  /**
 63   * A managed Ink root, similar to react-dom's createRoot API.
 64   * Separates instance creation from rendering so the same root
 65   * can be reused for multiple sequential screens.
 66   */
 67  export type Root = {
 68    render: (node: ReactNode) => void
 69    unmount: () => void
 70    waitUntilExit: () => Promise<void>
 71  }
 72  
 73  /**
 74   * Mount a component and render the output.
 75   */
 76  export const renderSync = (
 77    node: ReactNode,
 78    options?: NodeJS.WriteStream | RenderOptions,
 79  ): Instance => {
 80    const opts = getOptions(options)
 81    const inkOptions: InkOptions = {
 82      stdout: process.stdout,
 83      stdin: process.stdin,
 84      stderr: process.stderr,
 85      exitOnCtrlC: true,
 86      patchConsole: true,
 87      ...opts,
 88    }
 89  
 90    const instance: Ink = getInstance(
 91      inkOptions.stdout,
 92      () => new Ink(inkOptions),
 93    )
 94  
 95    instance.render(node)
 96  
 97    return {
 98      rerender: instance.render,
 99      unmount() {
100        instance.unmount()
101      },
102      waitUntilExit: instance.waitUntilExit,
103      cleanup: () => instances.delete(inkOptions.stdout),
104    }
105  }
106  
107  const wrappedRender = async (
108    node: ReactNode,
109    options?: NodeJS.WriteStream | RenderOptions,
110  ): Promise<Instance> => {
111    // Preserve the microtask boundary that `await loadYoga()` used to provide.
112    // Without it, the first render fires synchronously before async startup work
113    // (e.g. useReplBridge notification state) settles, and the subsequent Static
114    // write overwrites scrollback instead of appending below the logo.
115    await Promise.resolve()
116    const instance = renderSync(node, options)
117    logForDebugging(
118      `[render] first ink render: ${Math.round(process.uptime() * 1000)}ms since process start`,
119    )
120    return instance
121  }
122  
123  export default wrappedRender
124  
125  /**
126   * Create an Ink root without rendering anything yet.
127   * Like react-dom's createRoot — call root.render() to mount a tree.
128   */
129  export async function createRoot({
130    stdout = process.stdout,
131    stdin = process.stdin,
132    stderr = process.stderr,
133    exitOnCtrlC = true,
134    patchConsole = true,
135    onFrame,
136  }: RenderOptions = {}): Promise<Root> {
137    // See wrappedRender — preserve microtask boundary from the old WASM await.
138    await Promise.resolve()
139    const instance = new Ink({
140      stdout,
141      stdin,
142      stderr,
143      exitOnCtrlC,
144      patchConsole,
145      onFrame,
146    })
147  
148    // Register in the instances map so that code that looks up the Ink
149    // instance by stdout (e.g. external editor pause/resume) can find it.
150    instances.set(stdout, instance)
151  
152    return {
153      render: node => instance.render(node),
154      unmount: () => instance.unmount(),
155      waitUntilExit: () => instance.waitUntilExit(),
156    }
157  }
158  
159  const getOptions = (
160    stdout: NodeJS.WriteStream | RenderOptions | undefined = {},
161  ): RenderOptions => {
162    if (stdout instanceof Stream) {
163      return {
164        stdout,
165        stdin: process.stdin,
166      }
167    }
168  
169    return stdout
170  }
171  
172  const getInstance = (
173    stdout: NodeJS.WriteStream,
174    createInstance: () => Ink,
175  ): Ink => {
176    let instance = instances.get(stdout)
177  
178    if (!instance) {
179      instance = createInstance()
180      instances.set(stdout, instance)
181    }
182  
183    return instance
184  }