ink
Version:
React for CLI
132 lines (126 loc) • 6.72 kB
JavaScript
import { types } from 'node:util';
import { createElement } from 'react';
import Yoga from 'yoga-layout';
import { LegacyRoot } from 'react-reconciler/constants.js';
import reconciler from './reconciler.js';
import renderer from './renderer.js';
import { createNode } from './dom.js';
import RootNodeContext from './components/RootNodeContext.js';
/* eslint-disable jsdoc/require-asterisk-prefix -- `**Notes:**` is Markdown bold text, not a line prefix. */
/**
Render a React element to a string synchronously. Unlike `render()`, this function does not write to stdout, does not set up any terminal event listeners, and returns the rendered output as a string.
Useful for generating documentation, writing output to files, testing, or any scenario where you need the rendered output as a string without starting a persistent terminal application.
**Notes:**
- Terminal-specific hooks (`useInput`, `useStdin`, `useStdout`, `useStderr`, `useApp`, `useFocus`, `useFocusManager`) return default no-op values since there is no terminal session. They will not throw, but they will not function as in a live terminal.
- `useEffect` callbacks will execute during rendering (due to synchronous rendering mode), but state updates they trigger will not affect the returned output, which reflects the initial render.
- `useLayoutEffect` callbacks fire synchronously during commit, so state updates they trigger **will** be reflected in the output.
- The `<Static>` component is supported — its output is prepended to the dynamic output.
- If a component throws during rendering, the error is propagated to the caller after cleanup.
@example
```
import {renderToString, Text, Box} from 'ink';
const output = renderToString(
<Box padding={1}>
<Text color="green">Hello World</Text>
</Box>,
{columns: 40}
);
console.log(output);
```
*/
/* eslint-enable jsdoc/require-asterisk-prefix */
const renderToString = (node, options) => {
const columns = options?.columns ?? 80;
// Create a standalone root node — no stdout, stdin, or terminal bindings
const rootNode = createNode('ink-root');
// Capture static output from intermediate renders.
// The <Static> component uses useLayoutEffect to clear its children after
// the first commit. The reconciler's resetAfterCommit calls onImmediateRender
// when static content is dirty (and returns early, skipping the normal
// onRender callback), giving us a chance to capture it before it's cleared
// by the subsequent re-render.
let capturedStaticOutput = '';
rootNode.onStaticChange = () => {
capturedStaticOutput = '';
};
rootNode.onComputeLayout = () => {
rootNode.yogaNode.setWidth(columns);
rootNode.yogaNode.calculateLayout(undefined, undefined, Yoga.DIRECTION_LTR);
};
rootNode.onImmediateRender = () => {
const { staticOutput } = renderer(rootNode, false);
if (staticOutput !== '') {
capturedStaticOutput += staticOutput;
}
};
// Capture the first uncaught error so we can re-throw it after cleanup.
// React's reconciler catches component errors internally and reports them
// via onUncaughtError rather than letting them propagate. For a synchronous
// utility like renderToString, callers expect errors to throw.
let uncaughtError;
// Create a reconciler container in legacy (synchronous) mode.
// The four trailing callbacks are: onUncaughtError, onCaughtError,
// onRecoverableError, and onDefaultTransitionIndicator. Transition tracing is disabled.
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const container = reconciler.createContainer(rootNode, LegacyRoot, null, false, null, 'render-to-string', (error) => {
// eslint-disable-next-line @typescript-eslint/no-deprecated -- Error.isError is not available in Node.js 22.
uncaughtError ??= types.isNativeError(error)
? error
: new Error(String(error));
}, () => { }, () => { }, () => { }, null);
let didTeardownSucceed = false;
try {
let output;
let outputHeight;
let staticOutput;
try {
// Synchronously render the React tree into the container
reconciler.updateContainerSync(createElement(RootNodeContext.Provider, { value: rootNode }, node), container, null, () => { });
reconciler.flushSyncWork();
// Yoga layout has already been calculated by onComputeLayout during commit.
// Render the DOM tree to a string — this captures the dynamic (non-static) output.
({ output, outputHeight } = renderer(rootNode, false));
// Cleanup removes Static too; preserve the completed render before teardown.
staticOutput = capturedStaticOutput;
}
finally {
// Tear down: unmount the tree so the reconciler cleans up child nodes
// and runs effect cleanup functions. Child Yoga nodes are freed by the
// reconciler's removeChildFromContainer → freeYogaSubtree → freeRecursive.
reconciler.updateContainerSync(null, container, null, () => { });
reconciler.flushSyncWork();
didTeardownSucceed = true;
// Free the root yoga node itself (children already freed by reconciler)
rootNode.yogaNode.free();
}
// Re-throw after full cleanup so callers see the original error.
if (uncaughtError !== undefined) {
throw uncaughtError;
}
// The renderer appends a trailing newline to static output so dynamic output starts on a fresh line.
// A single blank dynamic row has empty text but still needs that separator.
if (outputHeight > 0) {
return staticOutput + output;
}
// Without dynamic output, strip the separator so renderToString returns clean output.
return staticOutput.endsWith('\n')
? staticOutput.slice(0, -1)
: staticOutput;
}
finally {
// Ensure native Yoga memory is freed even if rendering or teardown threw.
// Yoga nodes are WASM-backed and not garbage collected.
if (!didTeardownSucceed && rootNode.yogaNode) {
try {
// If reconciler teardown failed, some child nodes may not have been
// freed. Use freeRecursive to clean up the entire tree as best-effort.
rootNode.yogaNode.freeRecursive();
}
catch {
// Best-effort: node may already be partially freed
}
}
}
};
export default renderToString;
//# sourceMappingURL=render-to-string.js.map