Skip to content

Audit useTick callers and add React.memo to tick-driven leaves — measure before optimizing #563

@esengine

Description

@esengine

Summary

A user report flagged useTick() (120ms / ~8Hz) as a potential
source of TUI jank — multiple useTick callers in Spinner.tsx
and LiveRows.tsx re-rendering every frame. The general direction
is worth investigating, but the report's specific diagnosis has
errors that would mislead anyone acting on it directly. Filing a
narrower, measurement-first audit instead of treating it as a known
bug.

What's correct

  • useTick is wired to Ink 7's useAnimation at 120ms
    (src/cli/ui/ticker.tsx:20,52).
  • Spinner.tsx:19 calls it for frame rotation.
  • src/cli/ui/layout/LiveRows.tsx has 5 callsites (line
    50 useSlowTick, lines 110 / 164 / 221 / 297 useTick).

What the report got wrong

These need correcting in any follow-up so we don't act on bad
premises:

  1. "useAnimation triggers reconciliation of the whole Ink root."
    No. The comment at ticker.tsx:5-8 is explicit: Ink consolidates
    all useAnimation callers into a single shared interval. Each
    caller does a normal React state update — its own subtree
    re-renders, not the whole tree.

  2. "App.tsx is 3779 lines so the VDOM is huge." Line count is
    not VDOM size. Render output determines VDOM cost; file length
    doesn't.

  3. "8Hz ANSI diff is itself a perf issue." 120ms is a normal
    terminal animation cadence. Windows Terminal / Kitty / iTerm2
    handle it without strain. Slow terminals (SSH / tmux) can suffer,
    but the bottleneck there is per-frame work, not output rate.

The real cost shape is per-tick: subtree re-render → Yoga layout
pass → frame buffer diff → terminal write. The lever isn't fewer
ticks, it's smaller per-tick work.

What to actually do

Order matters. Don't optimize before measuring.

1. Measure first

Capture frame-time stats on three target environments:

  • Windows Terminal (native PowerShell)
  • macOS / Linux native terminal (Alacritty or iTerm2)
  • SSH + tmux on a typical remote (proxy for "slow terminal")

Run a representative session: streaming reply with multiple live
spinners + activity rows. Log per-frame:

  • React render time
  • Yoga layout time (if Ink exposes it)
  • ANSI diff byte count
  • Terminal write latency

Without this data, we'd be guessing.

2. Audit useTick callers

For each of the 6 callsites (1 in Spinner, 5 in LiveRows), ask:
does this animation actually need 8Hz? Many UI cues read fine at
3-4Hz:

  • "thinking..." dot pulses → 250-300ms is indistinguishable
  • elapsed-counter updates → already on useSlowTick (1Hz)
  • glyph rotations on idle status rows → 250ms+ is fine

Downgrade to useSlowTick (1Hz) where the visual difference is
imperceptible. Keep useTick only where the user genuinely sees
8Hz motion.

3. Add React.memo to tick-driven leaves

Spinner is a textbook React.memo candidate: pure props in, single
char out. Memoize so siblings don't re-render when only the spinner
frame changes. Same treatment for the useTick-calling components
in LiveRows where appropriate — wrap each tick-driven leaf in memo
with a stable props equality.

This caps the subtree size that has to re-render per tick.

4. Confirm TickerProvider pause coverage

ticker.tsx:37-39 exposes a disabled prop that flips isActive
to false, pausing all animations. Confirm it's wired to:

  • PLAIN_UI mode (already documented)
  • Modal overlays (already documented)
  • Idle-gate (already documented)
  • And any new path that should be quiescent (e.g. plan mode picker
    open, image preview, etc.) — verify each one.

A quiescent TUI should be byte-stable; any tick output during idle
is a leak.

Out of scope

  • Replacing Ink with a different renderer.
  • Lowering the global tick rate below 120ms — too aggressive without
    evidence, and would make legitimate animations feel sluggish.
  • Rewriting useTick / useSlowTick themselves — the primitives
    are fine; the question is who calls them and how.

Acceptance

  • Frame-timing measurements committed for three target envs
    (or at minimum: a documented local repro showing the worst case).
  • Each useTick callsite either justified at 120ms or downgraded
    to useSlowTick.
  • React.memo applied to Spinner and the appropriate tick-driven
    leaves in LiveRows.
  • No regression in animation smoothness on the fast-terminal path.

Why now

Self-noticed via a user report. No measured user-visible jank yet —
this is preventive. Same render-budget family as #551 (dashboard
unresponsive) and #557 (spinner stuck) — closing all three together
gives us a clean main-thread render budget story.

Relates to

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestrenderingTerminal rendering / flicker / repaint issuesv1Legacy TypeScript line (0.x) — v1 branch, maintenance only

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions