Skip to content

fix(ui): memoise the a11y tree so the paint loop stops re-laying out every frame - #152

Merged
tannevaled merged 1 commit into
mainfrom
fix/a11y-layout-spin
Aug 11, 2026
Merged

fix(ui): memoise the a11y tree so the paint loop stops re-laying out every frame#152
tannevaled merged 1 commit into
mainfrom
fix/a11y-layout-spin

Conversation

@tannevaled

Copy link
Copy Markdown
Contributor

The freeze

Since #149 the reader runs on go-widgets/window, whose accessibility bridge pulls Scene.A11yElements() on every paint frame, and run.go drives an unconditional 60 Hz repaint ticker. Each pull rebuilt the entire accessibility tree, and building it runs a full Scene.layout() pass — text measurement (wrapMeasured) for every card.

At 60 fps that is a complete feed re-layout 60×/sec on the render thread → one core pinned at 100 %+ → the UI freezes even while idle. A goroutine dump of the spinning process showed the hot loop:

cocoa.(*Window).paintFrame
  → window.elements → Handler.A11yElements → Scene.A11yTree
    → Scene.layout → cardHeight → cardTitleLines → wrapMeasured

with no aggregation goroutines in flight — i.e. the spin was the idle a11y pull, not a hung fetch.

The fix

Memoise the built tree against the scene's damage sequence (rev): a pull with no intervening change returns the cached slice; any state change bumps rev and forces a rebuild on the next pull, so a screen reader never sees a stale tree.

Measured

ui benchmark scene:

ns/op allocs
cached pull 1.0 0
full rebuild 9085 192

~8,700× per pull — and a real feed measures far more text per rebuild than this two-card scene.

  • 100 % statement coverage on ui retained (TestA11yTreeCachesUntilAChange covers hit + invalidation; benchmarks exercise both paths)
  • gofmt clean, full build green

🤖 Generated with Claude Code

…every frame

Since #149 the app runs on go-widgets/window, whose accessibility bridge pulls
Scene.A11yElements() on every paint frame, and run.go drives a 60 Hz repaint.
Each pull rebuilt the whole accessibility tree, and building it runs a full
layout pass — text measurement for every card included. At 60 fps that is a
complete feed re-layout 60 times a second on the render thread, pinning a core
and freezing the UI even while the app is idle (the spinning process's goroutine
dump showed paintFrame -> A11yElements -> A11yTree -> layout -> wrapMeasured with
no aggregation in flight).

Cache the built tree against the scene's damage sequence (rev): a pull with no
intervening state change returns the cached slice, and any change bumps rev and
forces a rebuild on the next pull, so the tree a screen reader sees never goes
stale.

Measured on the ui benchmark scene: a cached pull is 1.0 ns/op with 0 allocs
versus 9085 ns/op / 192 allocs to rebuild — and a real feed measures far more
text per rebuild than this two-card scene.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@tannevaled
tannevaled merged commit 70c0639 into main Aug 11, 2026
12 checks passed
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