markdown.engineering
Lesson 11

The Custom Ink Rendering Engine

How Claude Code turns a React component tree into terminal output — from reconciler commit to screen diff in microseconds.

7 source files
2 buffers, 0 GC pressure
React 19 reconciler
1 The Seven-Stage Rendering Pipeline

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.

Frame rendering pipeline — click a stage to inspect it
Reconciler
react-reconciler
commit
Virtual DOM
DOMElement tree
dirty flags
Yoga Layout
Flexbox on WASM
calculateLayout()
Render
renderNodeToOutput
blit or write
Output Ops
operations queue
clip / blit / write
Screen Buffer
packed Int32Array
per-cell storage
Cell Diff
front vs back
ANSI to stdout
Stage 1 — React Reconciler (reconciler.ts)
A custom 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.
Stage 2 — Virtual DOM (dom.ts)
Ink's DOM is a plain TypeScript object tree, not a browser DOM. Each node is a 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.
Stage 3 — Yoga Layout (layout/)
Yoga is a C++ Flexbox engine compiled to WASM, wrapped by a thin TypeScript adapter in 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.
Stage 4 — renderNodeToOutput (render-node-to-output.ts)
The recursive tree walk that converts a laid-out node into Output operations. For each node: (a) check if it's hidden (display:none) and clear its old position if needed. (b) check the blit fast path — if the node is clean, its position hasn't changed, and 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.
Stage 5 — Output Operations Queue (output.ts)
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).
Stage 6 — Screen Buffer (screen.ts)
Each screen is a packed Int32Array: 2 words per cell. Word 0 = charId (index into 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.
Stage 7 — Cell Diff → Terminal (log-update / termio)
The renderer returns a Frame containing the new screen, viewport dimensions, and cursor position. The frame is diffed against the previous frame's screen. The diff walks the damage bounding box only — cells outside the dirty region are skipped entirely. For each changed cell, the diff emits the minimal ANSI sequence: cursor movement (only when position changes), style transition (using 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.
2 The Reconciler — React Without a Browser

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.

Architecture Note

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.

3 The Virtual DOM and the Dirty Flag System

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.

ink-root
One per session. Holds the FocusManager. Always has a yogaNode.
ink-box
Maps to <Box>. Has yogaNode with full flex properties. Handles overflow clipping, scroll, borders, background.
ink-text
Maps to <Text>. Has yogaNode with a custom measure function that calls back JS for text width. Squashes child text nodes for rendering.
ink-virtual-text
A <Text> nested inside another <Text>. No yogaNode — invisible to the layout engine. Used for inline styling.
ink-raw-ansi
Pre-rendered ANSI content. Has a custom measure function. Bypasses squash/wrap/style — the string is written directly to the output buffer.
ink-link
OSC 8 hyperlink wrapper. No yogaNode — purely a metadata carrier for the URL.
ink-progress
No yogaNode. Progress bar rendering handled entirely in JS.
4 Yoga — Flexbox in WebAssembly

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.

Critical Detail

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.

5 renderNodeToOutput — The Blit Fast Path

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.

6 The Screen Buffer — Packed Int32 Cells

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.

PACKED CELL LAYOUT — 2 × Int32 per cell (8 bytes total)
word0
charId (32 bits)
index into CharPool
word1
styleId [31:17]
15 bits → StylePool index×2 + visible-on-space
width [1:0]
Narrow/Wide/SpacerTail/SpacerHead
Shared CharPool + HyperlinkPool across both screen buffers — blit copies integer IDs with no re-interning. BigInt64Array view enables single-call bulk zero-fill. styleId bit 0 = "visible on space" for fast invisible-space skipping.
Double Buffering
Back Buffer
Current frame render target.
Reset + written each frame.
swap on
frame end
Front Buffer
Previous frame. Source for
blit fast path + cell diff.
diff
changed cells
Terminal
Minimal ANSI sequence
written to stdout.
7 renderer.ts — Orchestrating a Frame

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.

What persists across frames
  • Output instance (charCache)
  • CharPool + HyperlinkPool (shared)
  • StylePool (session-lived)
  • nodeCache (blit rects)
  • front/back Screen buffers (swapped)
What resets each frame
  • Output operations list (cleared)
  • Screen cell data (zero-filled)
  • damage bounding box
  • scrollHint, scrollDrainNode
  • layoutShifted flag

Key Takeaways
  1. 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).
  2. Yoga is a WASM Flexbox engine, not CSS parsing. Ink runs the same layout algorithm as React Native. The layout adapter in layout/yoga.ts translates string enums to Yoga's integer constants, keeping the rest of the codebase decoupled from the WASM bindings.
  3. 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.
  4. 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.
  5. Alt-screen mode has three interlocking invariants. Yoga height clamped to terminalRows. Viewport height faked to terminalRows + 1. Cursor y clamped to terminalRows - 1. Each exists to prevent a different failure mode: content overflow, full-screen clear trigger, and cursor-restore LF scroll respectively.
  6. The reconciler actively avoids marking dirty for event handlers. Storing handlers in node._eventHandlers instead of node.attributes means 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.
  7. 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.
✓ Knowledge Check
1. A React component deep in the tree calls setState(). Which path does the renderer take for its clean siblings?
A Re-render all siblings because React's commit covers the whole tree
B Blit each clean sibling's rect from the previous frame's screen buffer
C Skip siblings entirely — they are not in the dirty subtree at all
D Re-run Yoga layout on all siblings before blitting
2. Why does renderNodeToOutput set prevScreen = undefined when an absolute-positioned node is removed?
A To prevent the cleared node from appearing in the cell diff output
B Because absolute nodes store their content in a separate buffer
C An absolute node may have painted over non-siblings; blitting prevScreen would restore those stale pixels under siblings' blit
D To force Yoga to recompute layout for the parent
3. What does bit 0 of a StylePool ID encode, and why does the diff loop care?
A Whether the style includes a hyperlink — hyperlinks need OSC 8 wrapping
B Whether the style is visible on space characters (background, inverse, underline) — the diff can skip spaces with fg-only styles using a single bitmask check
C Whether the style is a new intern or a cached one
D Whether the style came from a parent or was set directly on the element
4. Why is the Output class a command buffer (operations queue) rather than writing cells directly during the tree walk?
A Command buffers are required by the react-reconciler interface
B Direct writes would race with Yoga's layout computation
C It allows pass 1 to compute the damage bounding box from all clears before pass 2 executes blits and writes, and lets clip regions intersect correctly across nested nodes
D The Screen buffer is read-only during the tree walk phase
5. What would happen on the alt screen if the renderer did NOT fake viewport.height = terminalRows + 1?
A The cursor would be hidden permanently
B shouldClearScreen() would trigger a full-screen clear every frame because screen.height === viewport.height, causing visible flicker
C Yoga layout would overflow into scrollback history
D The CharPool would be reset between frames