Replace per-node ancestor walks with cumulative path transforms - #3794
Replace per-node ancestor walks with cumulative path transforms#3794mattgperry wants to merge 2 commits into
Conversation
calcProjection previously applied every ancestor's projection delta to each node's box via applyTreeDeltas - an O(nodes x depth) walk per frame. Each delta is an axis-aligned translate/scale, i.e. an affine map (p -> a * p + b) per axis, so a node's full ancestor correction composes in closed form from its parent's cached transform. Nodes now compute this once per updateProjection sweep (updatePathTransform), making the path work O(nodes). At depth 30 (1,200 nodes) this measured 31.1ms -> 9.2ms per 2s animation window for the path work. Shared transitions keep the legacy walk: they interleave scroll offsets and ancestor latestValues transforms whose origins depend on the box being projected, so they don't compose into one per-layer map. Also caches the display: contents check in setOptions - resolving options.visualElement.props.style for every ancestor of every node each frame was itself a hotspot. Co-authored-by: Cursor <cursoragent@cursor.com>
Greptile SummaryThe PR replaces repeated non-shared ancestor-delta walks with per-sweep cumulative affine path transforms while retaining the legacy path for shared transitions. It also caches
Confidence Score: 4/5The dynamic The affine transform optimization preserves the legacy geometry, but the accompanying display-state cache outlives the props from which it was derived and is subsequently trusted by both projection paths. Files Needing Attention: packages/motion-dom/src/projection/node/create-projection-node.ts Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
A["Root updateProjection"] --> B["Increment path-transform sweep"]
B --> C["Depth-sorted projection pass"]
C --> D{"Shared transition?"}
D -->|Yes| E["Walk ancestors with applyTreeDeltas"]
D -->|No| F["Resolve parent's cached affine transform"]
F --> G["Compose parent's projection delta"]
G --> H["Apply cumulative transform to layout box"]
E --> I["Calculate projection delta"]
H --> I
Reviews (1): Last reviewed commit: "Replace per-node ancestor walks with cum..." | Re-trigger Greptile |
| const { visualElement } = this.options | ||
| const style = visualElement | ||
| ? (visualElement.props as { style?: MotionStyle }).style | ||
| : undefined | ||
| this.isDisplayContents = | ||
| !!style && (style as { display?: string }).display === "contents" |
There was a problem hiding this comment.
Stale display-contents projection state
When a mounted layout ancestor changes style.display to or from contents without changing layoutDependency, VisualElement.update() replaces the props but setOptions() does not refresh isDisplayContents, causing descendant animations to incorrectly include or omit that ancestor's projection delta and project to the wrong box.
Knowledge Base Used: motion-dom node/projection layout system
Review finding: props can update between renders without setOptions being called, so caching isDisplayContents only in setOptions could go stale if style.display changes at runtime. Refresh it once per node per frame in propagateDirtyNodes - still keeping the props-chain resolution out of the per-ancestor inner loops. Co-authored-by: Cursor <cursoragent@cursor.com>
Summary
calcProjectionapplied every ancestor's projection delta to each node's box viaapplyTreeDeltas— an O(nodes × depth) walk every frame.updateProjectionsweep (updatePathTransform, invalidated by a sweep counter; the sweep is depth-sorted so parents always resolve first), making the path work O(nodes).layoutId/resumingFrom) keep the legacy walk: they interleave scroll offsets and ancestorlatestValuestransforms whose origins depend on the box being projected, so they don't compose into one per-layer map.display: contentscheck insetOptions— resolvingoptions.visualElement.props.stylefor every ancestor of every node each frame was itself a hotspot.At depth 30 (1,200 nodes, deep-tree stress test) the path work measured 31.1ms → 9.2ms per 2s animation window;
applyTreeDeltasdrops out of the profile's top hotspots entirely. Break-even is at shallow depth (~5); the win scales with tree depth.Test plan
motion-dom+framer-motionunit suites (508 + 814 passing)layout*.ts, 68 tests) against React 18