rust · tui

What survived the port

Jun 22, 2026 · 12 min read

A port is an X-ray of the original. Forced to rebuild every piece in a language with different affordances, you find out — empirically, not by opinion — which parts of a design were essential and which were accidents of the host ecosystem. That's not how rewrite-it-in-Rust stories usually get told — those go benchmarks, memory graphs, a victory lap about the borrow checker — but that framing treats the port as a destination, and the destination is the least interesting thing a port produces.

What crosses over unchanged was the actual design. What has to change shape was implementation detail. And what you refuse to carry at all marks the border where the original ends and its ecosystem begins.

This essay is the retrospective of a series that has been reading one such port a subsystem at a time: inkrs, a Rust port of Ink, the React-for-CLIs renderer. Previous essays went deep on single crates — measuring strings, wrapping styled text, the diff renderer, the <Static> accumulator. This one zooms out and sorts the whole project into the X-ray's three categories: unchanged, reshaped, refused.

For scale, the figures as the README reports them today: 1117 tests passing across 17 crates, 4 ignored. Keep that number in mind; the last two sections are about where those tests came from and which upstream tests are deliberately not among them.

What crossed over 1:1

Flexbox. Ink lays out its component tree with Yoga, the C++ flexbox engine built for React Native. inkrs swapped it for taffy, a Rust flexbox implementation, and the swap is close to invisible in the API. The README's feature table reads like a CSS reference:

Flexbox via taffy (row / column + reverse, flex_wrap), padding, margin (incl. negative), gap, width / height (cells + percent), min_* / max_*, aspect_ratio, align_items / justify_content / align_self / align_content, flex_grow / flex_shrink / flex_basis, display: none, overflow: hidden (per-axis), position: static / relative / absolute

None of that needed rethinking, because none of it was Yoga's. Flexbox is a spec with multiple independent implementations; Yoga and taffy are interchangeable because the design lived in the spec, not the library. That's the cleanest possible result: the layout language was essential, the layout engine was a dependency.

The hooks. Ink's hook surface crossed over nearly name-for-name. Three real signatures from the inkrs-hooks crate in inkrs, each next to its upstream original. Input:

// inkrs-hooks/src/input.rs — upstream: useInput(handler, { isActive })
pub fn use_input<F>(handler: F, is_active: bool)
where
    F: Fn(&str, &Key) + Send + Sync + 'static,

Focus, whose options struct is documented in the source as mirroring "the upstream useFocus({...}) argument bag" — same fields, same defaults:

// inkrs-hooks/src/focus.rs — upstream: useFocus({ autoFocus, isActive, id })
pub struct UseFocusOptions {
    /// Auto-focus this component if no other component is currently
    /// focused. Default `false`.
    pub auto_focus: bool,
    /// Whether this component participates in focus navigation.
    /// Default `true`.
    pub is_active: bool,
    /// Optional id, lets callers target this component via
    /// [`FocusManager::focus`].
    pub id: Option<String>,
}

pub fn use_focus(opts: UseFocusOptions) -> UseFocus

And the app handle, which hands you exit() just as upstream's useApp() hands you exit — the doc comment on the Rust function opens with "Hook equivalent of upstream's useApp()":

// inkrs-hooks/src/app.rs — upstream: useApp()
pub fn use_app() -> AppHandle

Even the Key struct passed to input handlers is a declared mirror, down to its doc comment:

/// Useful information about a keypress, mirror of upstream's `Key`.
#[derive(Debug, Clone, Default)]
pub struct Key {
    pub up_arrow: bool,
    pub down_arrow: bool,
    /// `true` when the Return/Enter key was pressed.
    pub return_key: bool,
    pub escape: bool,
    pub tab: bool,
    pub ctrl: bool,
    pub shift: bool,
    pub meta: bool,
    // … page_up/page_down, f1–f12, kitty event_type, …
}

The renames are exactly the ones the language forces and no more: JavaScript's upArrow becomes up_arrow by case convention, and return becomes return_key because return is a keyword in Rust. That is the most invasive change the entire hook vocabulary needed.

It's not just the names — the mechanics survived, including one that would have been easy to drop. Upstream Ink wraps its input handler in useEffectEvent so the handler a keypress invokes is always the latest render's closure, never a stale one. The use_input doc comment describes the port of that exact mechanism: the hook registers one stable trampoline on the input bus and refreshes a shared cell with the newest handler every render, explicitly noting that this "mirrors upstream Ink's useEffectEvent wrapper around handleData" (quoted here in translation — much of inkrs's commentary is in Dutch). Without it, a handler capturing per-render state would be frozen at its first-render values, and the doc comment bluntly calls that freezing a latent bug either way. When a port reproduces not just your API but your staleness semantics, the original got something right.

The component model. <Box>, <Text>, <Newline>, <Spacer>, <Static>, <Transform>, <Fragment> — the same seven components, per the README's table, doing the same seven jobs. Even <Static>, the subtlest of them, kept its upstream contract: items print once, permanently, above a repainting dynamic region.

So: the layout language, the hook vocabulary and its semantics, the component set. That's what Ink is, as revealed by rebuilding it.

What changed shape

One thing conspicuously did not cross over: React. Upstream Ink is a custom renderer plugged into react-reconciler — the same machinery that drives react-dom, retargeted at a terminal. There is no React in Rust, so inkrs drives dioxus instead. The README's architecture diagram puts the replacement one box to the right of the runtime:

┌───────────────┐         ┌──────────────────────────┐    ┌─────────────────┐
│ inkrs-runtime │◀───────▶│      inkrs-dioxus        │    │   inkrs-hooks   │
│  Instance     │         │  VirtualDom → Element    │    │  use_signal,    │
│  log-update   │         │  rsx! / inkrs_rsx!       │    │  use_input,     │
│  stdin loop   │         │  panic capture           │    │  use_focus,     │
│  Static accum │         │  event dispatch          │    │  use_app, …     │
└───────────────┘         └──────────────────────────┘    └─────────────────┘

inkrs-dioxus rebuilds a dioxus-core VirtualDom into an element tree, and the runtime renders that.

the render loop component your view code virtual tree (vdom) diff vs previous frame patch → terminal rebuild compare emit ops next tick previous frame (kept as baseline)
The render loop a reconciler drives, whatever the host: a component rebuilds into a virtual element tree, the tree is diffed against the previous frame, and only the resulting patch is written to the terminal — then the next tick starts the cycle over.

The replacement is not shape-preserving, and the README is candid about what falls out of it: reactivity in inkrs "is driven by dioxus-core's scope-rebuild model." When a signal changes, the scopes that read it are marked dirty and rebuilt on the next tick. There is no concurrent mode, no priority lanes, no time slicing, no scheduler deciding your render can wait.

For a terminal UI, it's worth asking what those React features were buying. Concurrent rendering exists to keep a 60fps browser UI responsive while React chews on a large tree — interruptibility as a defense against jank. A terminal frame is a few kilobytes of text rebuilt in microseconds; there is nothing to interrupt.

What the scope-rebuild model gives back is a simpler mental model: a tick is a synchronous rebuild-and-repaint, and you drive the ticks. The README's quickstart says it outright, in its comments as much as its code:

// `render` paints the first frame immediately. Hold the returned
// `Instance` and call `tick()` to repaint, or `wait_until_exit()`
// to block until `use_app().exit()` is called.
let mut app = render(|| {
    let mut count = use_signal(|| 0i32);

    use_input(move |_raw: &str, key: &Key| { /**/ }, true);

    let v = *count.read();
    inkrs_rsx! {
        box {
            border_style: "round",
            padding: 1,
            text { color: "cyan", "count = {v}" }
        }
    }
});

// Drive ticks however you like — a sleep-loop, tokio::select!, etc.
// The runtime is intentionally event-loop-agnostic.
let _ = app.wait_until_exit();

Rendering stopped being something a framework schedules around you and became a function you call. In exchange for losing a scheduler nobody's progress bar needed, every frame became predictable — the property that the panic-recovery machinery leans on when it promises that the next tick renders cleanly.

The npm micro-dependencies became crates

Ink sits on the pile of tiny single-purpose packages that the npm ecosystem is famous for, and routinely mocked for. The port kept the pile. The README's architecture diagram lists ten utility crates with no inkrs dependencies:

ansi-tokenizer   sanitize-ansi   measure-text   wrap-text
kitty-keyboard   input-parser    parse-keypress
cursor-helpers   log-update      colorize

The lineage is visible in the names: log-update is named after the npm package Ink uses for incremental redraw, colorize is documented as chalk-compatible, and measure-text's own API is a tell — its README row lists string_width and widest_line, the snake_case ghosts of the string-width and widest-line npm packages. Most of this list has carried an essay in this series on its own, which is itself the retrospective's point: these boundaries turned out to be conceptual, not just npm-cultural. "How wide is a string" and "how do I wrap styled text" are real, separable problems in any language, and they stayed separable through a language change.

But keeping them separate in Rust isn't nostalgia; it's mechanical sympathy. Cargo's unit of compilation is the crate. Independent crates compile in parallel, and an edit inside inkrs-render recompiles inkrs-render — not measure-text, not wrap-text, not the eight other utilities that haven't changed.

The same boundary also scopes the tests. The README's crate table can attach a number to each unit of behavior precisely because each crate owns its behavior:

Crate Tests What it does
wrap-text 33 wrap / hard / truncate-* while preserving SGR runs
input-parser 57 Stdin chunks → discrete keypress / paste events
parse-keypress 72 Higher-level keypress parser on top of input-parser
log-update 41 Incremental redraw of a dynamic terminal frame

npm's micro-package culture gets justified in terms of reuse; the Rust version of the same structure justifies itself in terms of build graphs and test ownership. Same shape, different reason — which suggests the shape was right; neither ecosystem copied it from the other.

The test suite was the real inheritance

The most valuable thing upstream Ink handed the port wasn't the source code. It was the test suite.

Source code answers "how does Ink do it": in TypeScript, on Node, with React attached, none of which transfers. The tests answer "what must be true when it's done," and that transfers completely. "你好" occupies four cells. A wrapped red line closes its SGR state before the newline and reopens it after. ESC [ A parses as the up arrow. Every one of those is a fact about terminals, not about JavaScript — so upstream's tests could be ported as a behavioral contract and used to drive the Rust implementation toward compatibility, case by case. Of the README's 1117, the single biggest block is inkrs-render's 431 tests, sitting exactly where Ink's own rendering tests are densest.

Porting a test suite forces a question the original never had to ask: what does "the same" mean? The README's answer is behavioral equality, not byte equality, and it names names.

Upstream uses the cli-cursor npm package for its show/hide-cursor sequences; the port's log-update "writes the same VT100 escapes but we don't assert byte-equality with the npm package." Upstream's borders come from boxen; the port's box rendering "matches visually but isn't byte-for-byte identical": it deliberately skips boxen's title, dimension, and right-padded-background quirks.

The contract worth porting is what appears on the user's screen. Asserting byte-for-byte fidelity to a specific npm package's output would have promoted that package's incidental choices into a specification — the exact confusion between design and dependency this whole essay is about.

The tests we refused to port

Which brings us to the README's most quietly interesting section, titled "Skipped upstream tests": a public list of upstream test categories that are intentionally not ported, each with a reason. Read as a design document, it's the X-ray's negative space: every entry is a claim that some part of "Ink" was actually a part of React, or npm, or Node.

The list, in full:

  • react-reconciler concurrent-mode tests — there is no React reconciler to test. These tests pin React's scheduling behavior, not Ink's rendering contract; porting them would mean reimplementing concurrent mode solely to satisfy its own tests.

  • devtools / inspector — "no equivalent for the Dioxus VirtualDom yet." The one honest not yet on the list, as opposed to a not ever.

  • node-pty interactive fixtures — upstream's stdin tests spawn real pseudo-terminal subprocesses. The port tests stdin through the Instance::push_stdin injection path instead: same parser, same dispatch, no process zoo. The pty was the fixture; the behavior under test ports fine without it.

  • cli-cursor byte equality and boxen byte equality — the two byte-versus-behavior cases from the previous section.

  • A handful of <Static> corner cases that depend on React's useLayoutEffect ordering — replaced by the port's own documented rule in inkrs-runtime's module docs: only the first <Static> in document order is tracked as the accumulator, its committed-item count is a single usize, and the committed prefix is assumed to only grow, because — as the module docs put it —

    There is no diffing between successive renders of the same <Static> node — we assume the children vec only grows. If the prefix changes (e.g. items get removed or reordered), the committed output stays as it was — there's no way to un-print to stdout.

    Upstream only supports one <Static> per tree anyway; the corner cases being skipped are shadows of React's effect ordering, not of the component's contract.

Notice what's not on the list: nothing about layout. Nothing about text measurement, wrapping, color, or keypresses. The refusals cluster entirely around the host ecosystem — React's scheduler, React's devtools, Node's process model, npm packages' output quirks. The suite kept every test about what a terminal UI does and declined every test about what JavaScript machinery does.

A port with no skip list hasn't been thought through; it's byte-copying someone else's dependencies into a new language. The skip list is the design work.

Port the contract, not the implementation

One series retrospective, then, in three lines.

What crossed unchanged: flexbox, seven components, the hook vocabulary with its semantics intact down to handler staleness. What changed shape: the reactive core, from React's scheduled reconciler to dioxus's call-it-yourself scope rebuild — trading a scheduler for predictability. What was refused: every test that pinned the ecosystem instead of the behavior.

That sorting was only possible because the port aimed at the contract — the tests, the on-screen behavior, the API's promises — and let each implementation be whatever its language wanted it to be. Aim at the implementation instead and you get the worst of both worlds: a Rust codebase shaped like a JavaScript one, carrying dependency quirks as hard requirements, with React's scheduling constraints faithfully reproduced and none of its tooling to show for it.

The React was scaffolding. What survived the port is what Ink actually was all along: a layout spec, seven components, a dozen hooks — and eleven hundred tests that pin down what a terminal should show.


Thoughts? Find me on LinkedIn.