Skip to content

perf(layout): per-subtree layout caching / RelayoutBoundary to avoid full-tree relayout on every change #142

Description

@TimLai666

Summary

A gpui/Flutter-inspired layout optimization. Today, any change that sets needsLayout re-runs layout over the entire widget tree from the root, even when only a small subtree actually changed. This proposes per-subtree layout caching (the layout-side analogue of the existing RepaintBoundary), so a localized change only re-measures the affected subtree.

Filing as a design discussion first per CONTRIBUTING.md (layout algorithms → open an issue first), and because correct cache invalidation is behavior-affecting and needs alignment before implementation.

Current behavior (grounded)

  • Idle frames already skip layout — good: Window.Frame only lays out when w.needsLayout is set (app/window.go:543). This is already better than "relayout every frame".
  • But when needsLayout is set, the whole tree is re-laid-out: w.layout()w.root.Layout(w.ctx, constraints) (app/window.go:546, 768). There is no per-node result cache and no per-subtree dirty granularity.
  • needsLayout is a single window-level bool, flipped to true by resize, animations, content/signal changes, SetRoot, etc. (app/window.go:186, 216, 319, 363, 465, 1032). Any one of these → O(tree) re-measure of every node.

So the cost model today is: cost(change) = O(total nodes), regardless of how local the change was. For large trees (dashboards, IDE layouts, big tables/trees) where one widget animates or one label changes, this re-measures everything every affected frame.

Prior art

  • FlutterRenderObject.layout() short-circuits when constraints are unchanged and the node isn't dirty; RelayoutBoundary stops dirty propagation when a node's size can't be affected by its parent. This repo already implements the paint side of this (RepaintBoundary, app/window.go:309 comment), so the layout side is a natural symmetric addition.
  • taffy (used by gpui) — caches per-node measured sizes keyed by the input constraints + a style/version stamp, and reuses them across layout passes.

Proposed design (phased)

Phase 1 — per-node measure cache + layout-dirty flag

  • Add to WidgetBase: a small layout cache {lastConstraints geometry.Constraints, lastSize geometry.Size, valid bool} and a MarkNeedsLayout() that clears valid and propagates up to the nearest relayout boundary (or the window).
  • Containers, before calling a child's Layout(ctx, constraints), check: if the child's cache is valid and lastConstraints == constraints, reuse lastSize and skip the recompute.
  • Widgets call MarkNeedsLayout() when a layout-affecting property changes (text/content, font size, padding, visibility, children, explicit size). This is distinct from SetNeedsRedraw (paint-only) and from the window-level needsLayout (which becomes the "root got dirtied" signal).

Phase 2 — RelayoutBoundary

  • Mirror RepaintBoundary: a node that is laid out with tight constraints (its size cannot change due to descendant changes) becomes a relayout boundary, so a descendant's MarkNeedsLayout() stops propagating there instead of reaching the root. This bounds cost(change) to the boundary's subtree.

Phase 3 (optional) — constraint rounding / size-stamp reuse à la taffy for nodes measured multiple times in one pass.

Risks / why design-first

  • The hard part is invalidation correctness: if a widget mutates a layout-affecting property without calling MarkNeedsLayout(), you get a silent stale-layout bug that existing unit tests won't catch. Getting the set of "layout-affecting" mutations right across every widget is the real work and the real risk.
  • Mitigation: land Phase 1 mechanism on WidgetBase first (default conservative: cache only honored when the framework can prove the node wasn't touched), wire it into primitives/* and a few core/* widgets with golden/integration tests, and only then expand. A debug mode that re-runs uncached layout and asserts the cached result matches would catch missing invalidations during development.

Relationship to existing work

Offer

If the approach and especially the invalidation strategy look right, I'm happy to implement Phase 1 incrementally (base mechanism + tests + wire into primitives and a representative set of core widgets), then iterate. Wanted to align on the design and the "layout-affecting mutation" contract before writing code, since it touches WidgetBase and every widget's mutation paths.

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions