Skip to content

[medium] perf(widgets): keep ink and React off the status line render path - #636

Open
elhoim wants to merge 3 commits into
sirmalloc:mainfrom
elhoim:perf/lazy-widget-editors
Open

elhoim wants to merge 3 commits into
sirmalloc:mainfrom
elhoim:perf/lazy-widget-editors

Conversation

@elhoim

@elhoim elhoim commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

BLUF

  • Priority: medium.
  • What: widget modules no longer import ink or React at runtime. Widget.renderEditor now returns a plain descriptor ({ kind, props }), and the editor components move unchanged into src/tui/components/widget-editors/. That folder is only reached through the lazy import('./tui').
  • Why: perf: keep the TUI off the status line render path #575 took the TUI off the render path, but eleven widget modules still imported ink/React for their editor components. The registry builds every widget at load, so every status line render still evaluated ink, react, react-reconciler and yoga-layout, and yoga-layout instantiates its WASM when the module loads.
  • Measured: the chunk statically imported by the entry shrinks from 2.22 MB to 1.27 MB and no longer contains ink, react or yoga. Median CPU per render drops 14% on a heavy config, 18% on the default config and 26% for --version. Rendered output is byte-identical.
  • Update after merging main. Main has since added two React modules under src/widgets/shared that only editors use: the useTextCursor hook (text-cursor.ts, also used by the new label editor) and searchable-option-editor.tsx (now behind the locale and timezone editors). The merge moves both, with their tests, into src/tui/components/widget-editors/ so the no-ink/React guard still holds. It also ports main's later changes to the split widgets (custom-command display-width truncation, Link URL sanitizing, the cwd label and segment fixes, Number.parseInt) into the new files. Two new upstream glyph tests read slots from the editor descriptor instead of a React element's props. The entry's static closure is still ink/React/yoga-free (1.28 MB shared chunk vs 2.23 MB on main).

Details

  • src/types/Widget.ts: adds WidgetEditorDescriptor. renderEditor?(props) now returns WidgetEditorDescriptor | null instead of React.ReactElement | null. SymbolSlot moves here, and symbol-override re-exports it.
  • CustomText, CustomSymbol, CustomCommand, Link, Skills and CurrentWorkingDir go from .tsx to .ts. So do the shared symbol-override, max-width, speed-widget, locale-editor and timezone-editor. Each one keeps its render logic and returns a descriptor from renderEditor. Helper names and signatures are unchanged (renderSymbolSlotsEditor, renderMaxWidthEditor, renderSpeedWidgetEditor, renderUsageLocaleEditor, renderUsageTimezoneEditor), so the ~30 widgets that call them needed no edits. The only other changes are the return type annotation in BlockResetTimer and WeeklyResetTimer.
  • The 11 editor components move verbatim to src/tui/components/widget-editors/*Editor.tsx. renderWidgetEditor(descriptor) in index.tsx maps each kind to its component. ItemsEditor is the only caller: it renders renderWidgetEditor(editor), and still renders nothing when a widget returns null, as before. The few model helpers these editors use (Link's buildMetadata etc., Skills' list-limit helpers, the speed WINDOW_EDITOR_ACTION) are now exported from their widget modules.
  • The widget registry still builds every widget eagerly. Now that widget modules are ink-free, that costs nothing measurable, so it is left alone.
  • I kept a data descriptor instead of a registry that the TUI fills at startup, so there is no import-order dependency. Widget tests that call renderEditor directly (e.g. GitRootDir) pass unchanged.
  • Tests: the three editor tests move next to their components. The new widget-editors/__tests__/index.test.ts checks two things. First, every editor a widget opens from its keybinds maps to a React element. Second, a guard fails if any module under src/widgets imports ink or react as a value. The guard fails on main, where 11 modules do.
  • Overlap with my other open PRs: [low] fix(custom-command): truncate maxWidth output by display width, not string length #626 edits src/widgets/CustomCommand.tsx, which this PR splits into src/widgets/CustomCommand.ts and src/tui/components/widget-editors/CustomCommandEditor.tsx. Whichever PR merges second needs a small rebase. feat: Add RClone Queue widget + code-review fixes #523 adds a .tsx widget; if it keeps an ink editor it would need the same descriptor pattern, and the new guard test would flag it.

Measurements

Built with bun run build (--splitting). The static import closure of dist/ccstatusline.js:

shared chunk ink / react / react-reconciler / yoga-layout modules in the static closure
main (35440e4) 2,219,391 B 55 / 4 / 6 / 4
this PR 1,271,391 B 0 / 0 / 0 / 0 (they now sit only in the lazy TUI chunk)

The runs alternated base and patched builds, each process fork/exec'd with CPU = user+sys including reaped children. 25 passes on node 24. Load1 during the run was 54.9 to 71.1 (median 62.2) on 6 cores. The host was heavily loaded, so compare the ratios rather than the absolute numbers.

arm CPU median (ms) CPU p90 (ms) wall median (ms)
control node -e 0 108.6 143.7 998
main --version 1086.1 1194.9 8527
this PR --version 806.1 (-26%) 900.0 6637
main heavy config, 5 MB transcript 2009.1 2312.7 14413
this PR heavy config, 5 MB transcript 1723.8 (-14%) 1949.6 13425
main default config, 5 MB transcript 1531.0 1728.3 11383
this PR default config, 5 MB transcript 1259.7 (-18%) 1390.4 9843

Output: stdout+stderr are byte-identical between the two builds on the heavy, heavy-ttl0, default, default-ttl0, livelike-ttl0, git17-ttl0 and block configs, each with 1 MB, 5 MB and no transcript (21/21). --version is identical too.

Checks

  • bun run lint (tsc + eslint): clean.
  • bun run build: OK (as above).
  • bun test: 2289 pass / 69 fail on this loaded host. Every failure is a timing-sensitive test: ink input tests in the TUI menus and editors, the custom-command capture tests and fetchUsageData. The same TUI/editor/widget subset fails on unmodified main under the same load: 15 failures there vs 14 here, with the same test names. The usage and custom-command files fail too: 36 on main vs 33 here. This full run was taken before the two new tests in widget-editors/__tests__/index.test.ts were added. Those pass (2/2) when run on their own.

🤖 Generated with Claude Code

sirmalloc#575 moved the TUI behind import('./tui'), but eleven widget modules
(CustomText, CustomSymbol, CustomCommand, Link, Skills, CurrentWorkingDir
and the shared symbol-override, max-width, speed-widget, locale-editor and
timezone-editor helpers) still imported ink and React as values for their
editor components. The widget registry builds every widget at load, so
every render still evaluated ink, react, react-reconciler and yoga-layout
(which instantiates its WASM at module load).

Widget.renderEditor now returns a plain WidgetEditorDescriptor naming the
editor ({ kind, props[, slots] }) instead of a React element. The editor
components move unchanged to src/tui/components/widget-editors, and
renderWidgetEditor() there maps a descriptor to its component; ItemsEditor
is the only caller. The helper names and signatures used by the other
widgets (renderSymbolSlotsEditor, renderMaxWidthEditor, ...) are kept, so
those widgets are untouched.

Built with `bun run build`, the chunk statically imported by the entry
drops from 2.22 MB to 1.27 MB and no longer contains ink, react,
react-reconciler or yoga-layout. Rendered output is byte-identical on the
heavy, default, livelike, git17 and block configs.

Interleaved bench (25 passes, node, host load 55-71 on 6 cores), CPU
median: --version 1086 -> 806 ms (-26%), heavy config 2009 -> 1724 ms
(-14%), default config 1531 -> 1260 ms (-18%).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
elhoim and others added 2 commits October 10, 2026 11:31
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Port upstream editor changes (text cursor bar, symbol slots, link/cwd
modes) onto the lazy widget-editor modules. BlinkingCursor and
cursor-blink move from src/widgets/shared to src/tui/components so
widget modules stay free of ink/React.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0171BeL2f3DYmsHicVsa2Tak
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant