Repository navigation
Description
A single animated element makes an otherwise idle app cost ~23% of a core, because every render request walks the entire renderable tree — re-querying yoga layout and visibility for every node, whether or not anything changed.
Measured with opencode's TUI, but nothing in the hot path belongs to opencode.
Profile
bun --cpu-prof --cpu-prof-md, 330s of an idle session with one <spinner> turning at 80ms, 10429 samples at 1ms:
| Self% | Self | Function | Location |
|---|---|---|---|
| 14.7% | 48.69s | updateLayout |
src/Renderable.ts:1440 — Total 100% |
| 6.5% | 21.49s | render |
native |
| 5.9% | 19.53s | get visible |
src/Renderable.ts:338 |
| 4.1% | 13.81s | tick |
opentui-spinner scheduler |
| 3.4% | 11.46s | bufferDrawTextBufferView |
native |
| 2.9% | 9.64s | updateTextFromNodes |
src/renderables/Text.ts:86 |
| 2.2% | 7.41s | yogaNodeGetComputedLayout |
native |
| 2.2% | 7.32s | updateLayout |
src/Renderable.ts:1397 |
| 2.0% | 6.85s | updateFromLayout |
src/Renderable.ts:1094 |
| 1.3% | 4.45s | renderSelf |
src/renderables/Box.ts:269 |
updateLayout (Renderable.ts:1440) is entered from render 642 times and recurses into itself 3942 times, calling get visible (131 direct) and yogaNodeGetComputedLayout per node.
Grouped: layout + visibility ≈ 28% of all samples, drawing ≈ 12%, text re-derivation ≈ 6%, the spinner scheduler ≈ 5%.
Why this looks like a missing dirty check
The spinner itself is well behaved — its tick advances an index and calls this.requestRender() on itself, no tree mutation:
this._currentFrameIndex = (this._currentFrameIndex + 1) % this._frames.length
this.requestRender()Nothing about that changes geometry, yet the whole tree is re-laid-out. yogaNodeIsDirty already appears in the profile (0.9% in an earlier run), so the information needed to skip an unchanged subtree exists — it just doesn't stop the recursion.
What it is not
Two hypotheses I tested against the same app and both are wrong:
| variant | idle CPU |
|---|---|
| baseline (deep tree, 80x24) | 23.1%, 23.1% |
| same app, ~8x fewer children mounted | 20.9%, 21.4% |
| same app, viewport 40x10 instead of 80x24 | 22.7%, 21.6% |
So it tracks neither the number of mounted children nor the number of visible cells. ScrollBox's viewportCulling (default true) filters what is painted via _getVisibleChildren(), but every child still participates in the layout walk.
Suggestion
Prune the updateLayout recursion when a subtree's yoga node is not dirty and its computed position/size is unchanged. An animation that changes one glyph inside a fixed-size box should cost one cell of redraw, not an O(tree) walk.
Separately, opentui-spinner's scheduler accounts for ~5% of all samples (tick self 4.1% plus _advanceFrame) purely to advance a frame index — worth a look on its own.
Versions: @opentui/core 0.4.5, opentui-spinner 0.0.7, bun 1.3.13, Linux.
Happy to share the raw .cpuprofile (8.4MB) or re-run with different flags.
opencode-idle-spinner.cpuprofile.gz
opencode-idle-spinner-profile.md.gz
Activity
dajiaohuang commentedon Aug 18, 2026
The profile points to a real hot path, but there are two materially different optimization boundaries in the current renderer:
Yoga dirtiness alone is not a safe paint predicate: text/style/cursor/animation state can change without a layout change. Could a maintainer indicate which direction fits the renderer's intended invalidation model, and what benchmark/invariant should gate the change (for example, no output change across the existing render snapshots plus a target reduction for the 10k-node spinner case)? That decision determines whether this is a focused traversal optimization or a broader invalidation redesign.
simonklee commentedon Aug 25, 2026
@jclaveau Thanks for the issue on this. @kommander is looking into this atm and it has been on our backlog for some time. It's one perf issue we really want to land.
simonklee commentedon Nov 10, 2026
Thanks for the detailed report. Since the render tree moved to native (#1479, #1783, #1951), a spinner tick no longer walks the tree or repaints unchanged rows. If you still see high idle CPU on a current release, please reopen with numbers.