Skip to content

Replace per-node ancestor walks with cumulative path transforms - #3794

Closed
mattgperry wants to merge 2 commits into
mainfrom
projection-cumulative-tree-transforms
Closed

Replace per-node ancestor walks with cumulative path transforms#3794
mattgperry wants to merge 2 commits into
mainfrom
projection-cumulative-tree-transforms

Conversation

@mattgperry

Copy link
Copy Markdown
Collaborator

Summary

  • calcProjection applied every ancestor's projection delta to each node's box via applyTreeDeltas — an O(nodes × depth) walk every frame.
  • Each ancestor delta is an axis-aligned translate/scale, i.e. per axis an affine map (p → a·p + b), 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, invalidated by a sweep counter; the sweep is depth-sorted so parents always resolve first), making the path work O(nodes).
  • Shared transitions (layoutId/resumingFrom) 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.

At depth 30 (1,200 nodes, deep-tree stress test) the path work measured 31.1ms → 9.2ms per 2s animation window; applyTreeDeltas drops 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-motion unit suites (508 + 814 passing)
  • All layout Cypress specs (layout*.ts, 68 tests) against React 18
  • Profiled deep-tree layout stress before/after via V8 sampling profiler

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-apps

greptile-apps Bot commented Aug 17, 2026

Copy link
Copy Markdown

Greptile Summary

The 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 display: contents detection, but that cache is not refreshed for ordinary style-prop updates.

  • Adds cumulative per-axis path transforms and sweep-based invalidation.
  • Applies cached transforms to non-shared projection boxes in constant time per node.
  • Retains scroll and latestValues handling through the legacy shared-transition walk.
  • Extracts tree-scale snapping and caches ancestor display: contents state.

Confidence Score: 4/5

The dynamic display cache must be refreshed before merging because ordinary style updates can leave descendant layout projections using the wrong ancestor-delta behavior.

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

Filename Overview
packages/motion-dom/src/projection/node/create-projection-node.ts Adds the cumulative transform cache and non-shared fast path, but caches display: contents across style updates that do not call setOptions().
packages/motion-dom/src/projection/geometry/delta-apply.ts Extracts tree-scale snapping and switches the legacy walk to the projection node's cached display flag.
packages/motion-dom/src/projection/node/types.ts Defines the cumulative affine-transform shape and exposes its projection-node lifecycle fields.

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
Loading

Reviews (1): Last reviewed commit: "Replace per-node ancestor walks with cum..." | Re-trigger Greptile

Comment on lines +1170 to +1175
const { visualElement } = this.options
const style = visualElement
? (visualElement.props as { style?: MotionStyle }).style
: undefined
this.isDisplayContents =
!!style && (style as { display?: string }).display === "contents"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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>
@mattgperry mattgperry closed this Aug 18, 2026
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