Every frame — whether triggered by a spinner tick, a streaming token, or a window resize — passes through seven stages. Click any stage to see what happens inside it.
commit
dirty flags
calculateLayout()
blit or write
clip / blit / write
per-cell storage
ANSI to stdout
react-reconciler host is wired to ink's own DOM instead of the browser's. When React commits a tree update, it calls host methods like createInstance, commitUpdate, appendChild, and removeChildFromContainer. Each of those calls lands in dom.ts which mirrors the mutation into the Yoga node tree and calls markDirty() to walk up the ancestor chain, setting dirty flags on every parent. At the end of every commit, React calls resetAfterCommit(rootNode), which triggers onComputeLayout() then onRender() — the two-phase handoff to Yoga and the renderer.
DOMElement with a nodeName (ink-root, ink-box, ink-text, ink-virtual-text, ink-raw-ansi, etc.), style, attributes, childNodes, an optional yogaNode, and a dirty boolean. The dirty flag is the renderer's primary signal — a clean node with an unchanged layout position can be blitted from the previous frame instead of re-rendered, giving O(changed cells) performance for steady-state frames like a spinner tick or a streaming token appended to a fixed-height box.
layout/yoga.ts. The adapter translates Ink's abstract LayoutNode interface (with string enums like 'flex-start') into Yoga's numeric constants. calculateLayout(width, height) runs the full flexbox algorithm across the mirrored Yoga tree, filling in getComputedLeft(), getComputedTop(), getComputedWidth(), and getComputedHeight() on every node. Text nodes get a custom measure function that calls back into JS to measure rendered text width before layout is computed. Yoga caches layout results — nodes that haven't changed don't recompute. The getYogaCounters() call in commit logging exposes visited, measured, cacheHits, and live for profiling.
prevScreen is available, emit a single output.blit() call that copies cells from the previous frame's screen. This avoids descending into the subtree entirely. (c) for dirty nodes, emit output.clear() at the old position, then render the node: ink-text nodes go through squash → wrap → style → write; ink-box nodes set up clip regions and recurse; ink-raw-ansi nodes write pre-built ANSI strings directly. The nodeCache stores each rendered node's {x, y, width, height} for next-frame blit checks.
Output is a command-buffer abstraction. During the tree walk it collects operations in order: write (text + ANSI at x,y), blit (copy a region from prevScreen), clear (zero a rect), clip/unclip (push/pop a clipping rectangle for overflow:hidden), shift (hardware scroll — DECSTBM rows), and noSelect (mark non-selectable gutters). The operations are not executed during the walk. output.get() replays them in two passes: pass 1 expands the damage bounding box from all clear regions; pass 2 executes each operation in order — blitting rows via TypedArray.set(), applying clip intersections, and tokenizing written text into ClusteredChar[] via the charCache (persistent across frames — most lines don't change so tokenize+grapheme is a cache hit).
CharPool). Word 1 = styleId[31:17] | hyperlinkId[16:2] | cellWidth[1:0]. This layout halves memory accesses in the diff loop and eliminates per-cell object allocation (a 200×120 terminal would otherwise allocate 24,000 Cell objects per frame). CharPool and HyperlinkPool are shared across both buffers, so blit can copy integer IDs directly with no re-interning. The screen also carries a damage bounding box, a per-cell noSelect bitmap, and a per-row softWrap array for selection copy.
StylePool.transition(fromId, toId) — cached as a pre-serialized string after first call for any given pair), and the character itself. Wide characters (CJK, emoji) write a spacer cell so cursor positioning stays correct. The result is a minimal byte sequence written to stdout.
React's reconciler is designed to be retargetable. By implementing the host config interface, Ink gets all of React's state management, hooks, Suspense, and concurrent features for free — targeting a terminal instead of the DOM.
resetAfterCommit calls onComputeLayout() first, then onRender(). This two-phase separation is load-bearing: Yoga's calculateLayout() must complete before the renderer reads any getComputedLeft() / getComputedTop() values. In test mode, only onImmediateRender() is called to skip the async render cycle entirely.
The dirty flag is the single most important optimization in the renderer. It enables the blit fast path that makes steady-state frames near-free.
FocusManager. Always has a yogaNode.<Box>. Has yogaNode with full flex properties. Handles overflow clipping, scroll, borders, background.<Text>. Has yogaNode with a custom measure function that calls back JS for text width. Squashes child text nodes for rendering.<Text> nested inside another <Text>. No yogaNode — invisible to the layout engine. Used for inline styling.Ink uses the same Yoga layout engine as React Native. The layout adapter in layout/yoga.ts wraps Yoga's C API, translating string enums to integer constants and abstracting away WASM memory management.
When a DOM node is removed, the reconciler calls cleanupYogaNode(), which calls clearYogaNodeReferences(node) BEFORE yogaNode.freeRecursive(). The order matters: freeing first, then clearing, would leave dangling WASM memory references in any concurrent code still holding a reference to the node. The comment explicitly warns about this.
The blit fast path is what separates a 60fps terminal renderer from one that re-draws everything on every keystroke. The key invariant: a node is safe to blit if and only if it is clean, its position hasn't changed, and the previous screen is valid.
The screen buffer is the central data structure of the renderer. Every design decision in it exists to eliminate allocation and minimize memory bandwidth in the diff loop.
index into CharPool
15 bits → StylePool index×2 + visible-on-space
15 bits → HyperlinkPool
Narrow/Wide/SpacerTail/SpacerHead
Reset + written each frame.
frame end
blit fast path + cell diff.
changed cells
written to stdout.
createRenderer returns a closure that is called once per frame. It captures one Output instance across frames so the charCache persists — the main performance win for steady-state rendering.
- Output instance (
charCache) - CharPool + HyperlinkPool (shared)
- StylePool (session-lived)
- nodeCache (blit rects)
- front/back Screen buffers (swapped)
- Output operations list (cleared)
- Screen cell data (zero-filled)
- damage bounding box
- scrollHint, scrollDrainNode
- layoutShifted flag
- The dirty flag is the core optimization. A clean node with unchanged layout blit from the previous screen — no re-render, no re-tokenize, no re-style. Steady-state frames (spinner tick, streaming append) touch O(changed cells), not O(total cells).
- Yoga is a WASM Flexbox engine, not CSS parsing. Ink runs the same layout algorithm as React Native. The layout adapter in
layout/yoga.tstranslates string enums to Yoga's integer constants, keeping the rest of the codebase decoupled from the WASM bindings. - The screen buffer is a typed array, not objects. 2 Int32s per cell in a contiguous ArrayBuffer eliminates per-cell GC pressure. Shared CharPool and HyperlinkPool across both buffers mean blit copies integer IDs with no re-interning — the diff loop compares ints, not strings.
- The Output class is a command buffer, not an immediate renderer. Operations are collected during the tree walk and replayed in
output.get(). This separation allows clip regions and blit operations to interact correctly and lets pass 1 compute the damage bounding box before pass 2 executes anything. - Alt-screen mode has three interlocking invariants. Yoga height clamped to
terminalRows. Viewport height faked toterminalRows + 1. Cursor y clamped toterminalRows - 1. Each exists to prevent a different failure mode: content overflow, full-screen clear trigger, and cursor-restore LF scroll respectively. - The reconciler actively avoids marking dirty for event handlers. Storing handlers in
node._eventHandlersinstead ofnode.attributesmeans a parent re-render that creates a new callback closure doesn't trigger a repaint. A key design that prevents handler churn from causing visual flicker. - The charCache in Output is the tokenizer hot path accelerator. Most lines don't change between frames. By persisting the charCache across frames (capped at 16384 entries to bound memory), tokenize + grapheme clustering becomes a map lookup for unchanged lines. This is the primary win for streaming responses — each new token only tokenizes its own line.
setState(). Which path does the renderer take for its clean siblings?renderNodeToOutput set prevScreen = undefined when an absolute-positioned node is removed?viewport.height = terminalRows + 1?