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
- Flutter —
RenderObject.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.
Summary
A gpui/Flutter-inspired layout optimization. Today, any change that sets
needsLayoutre-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 existingRepaintBoundary), 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)
Window.Frameonly lays out whenw.needsLayoutis set (app/window.go:543). This is already better than "relayout every frame".needsLayoutis 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.needsLayoutis 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
RenderObject.layout()short-circuits when constraints are unchanged and the node isn't dirty;RelayoutBoundarystops 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.Proposed design (phased)
Phase 1 — per-node measure cache + layout-dirty flag
WidgetBase: a small layout cache{lastConstraints geometry.Constraints, lastSize geometry.Size, valid bool}and aMarkNeedsLayout()that clearsvalidand propagates up to the nearest relayout boundary (or the window).Layout(ctx, constraints), check: if the child's cache isvalidandlastConstraints == constraints, reuselastSizeand skip the recompute.MarkNeedsLayout()when a layout-affecting property changes (text/content, font size, padding, visibility, children, explicit size). This is distinct fromSetNeedsRedraw(paint-only) and from the window-levelneedsLayout(which becomes the "root got dirtied" signal).Phase 2 —
RelayoutBoundaryRepaintBoundary: a node that is laid out with tight constraints (its size cannot change due to descendant changes) becomes a relayout boundary, so a descendant'sMarkNeedsLayout()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
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.WidgetBasefirst (default conservative: cache only honored when the framework can prove the node wasn't touched), wire it intoprimitives/*and a fewcore/*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
layout/) — that cut allocations; this cuts redundant work.RepaintBoundarymental model and the window-levelneedsLayoutgate; nothing here regresses the idle-frame skip.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
WidgetBaseand every widget's mutation paths.