Include parent CSS scales in treeScale for layout animations#3623
Include parent CSS scales in treeScale for layout animations#3623mattgperry wants to merge 3 commits intomainfrom
Conversation
Greptile SummaryThis PR fixes layout animations being misaligned when child elements animate inside a parent that has a static CSS Key changes:
Notable design consideration: The Minor bug: When Confidence Score: 4/5
Important Files Changed
Sequence DiagramsequenceDiagram
participant Root as Root Node (update)
participant Node as Each Projection Node
participant DOM as DOM (getBoundingClientRect)
participant DeltaApply as applyTreeDeltas
Root->>Node: resetTransformStyle()<br/>(restores latestValues transforms to DOM)
Root->>Node: updateLayout()
Node->>DOM: measure(false) → measuredBox<br/>(getBoundingClientRect, includes all ancestor transforms)
Node->>DOM: offsetWidth / offsetHeight<br/>(layout box, no transforms)
Node->>Node: if !hasScale(latestValues) && !isSVG<br/> cssScale = measuredBox / offsetWidth
Note over Node: cssScale is CUMULATIVE<br/>(includes all ancestor CSS+motion scales)
Root->>DeltaApply: applyTreeDeltas(box, treeScale, treePath)
loop for each node in treePath (root → leaf)
DeltaApply->>DeltaApply: treeScale *= delta.x.scale (projectionDelta)
alt node.cssScale is set
DeltaApply->>DeltaApply: ownCssScale = node.cssScale / prevCssScale
DeltaApply->>DeltaApply: treeScale *= ownCssScale
DeltaApply->>DeltaApply: prevCssScale = node.cssScale
end
end
DeltaApply-->>Root: treeScale now includes CSS scales
Last reviewed commit: 47c5e60 |
| if (offsetWidth && offsetHeight) { | ||
| const { measuredBox } = this.layout | ||
| const measuredWidth = | ||
| measuredBox.x.max - measuredBox.x.min | ||
| const measuredHeight = | ||
| measuredBox.y.max - measuredBox.y.min | ||
| const scaleX = measuredWidth / offsetWidth | ||
| const scaleY = measuredHeight / offsetHeight | ||
| if ( | ||
| Math.abs(scaleX - 1) > 0.0001 || | ||
| Math.abs(scaleY - 1) > 0.0001 | ||
| ) { | ||
| this.cssScale = { x: scaleX, y: scaleY } | ||
| } else { | ||
| this.cssScale = undefined | ||
| } |
There was a problem hiding this comment.
Stale cssScale when offsetWidth/offsetHeight is zero
When offsetWidth or offsetHeight is falsy (zero), the inner if block is skipped but this.cssScale is never reset to undefined. If the element previously had a non-zero width with a CSS scale applied, and later gets offsetWidth = 0 (e.g., width: 0 or temporarily display: none then back), the stale cssScale persists and would incorrectly influence treeScale for descendant layout animations.
The fix is to reset cssScale when dimensions are zero:
| if (offsetWidth && offsetHeight) { | |
| const { measuredBox } = this.layout | |
| const measuredWidth = | |
| measuredBox.x.max - measuredBox.x.min | |
| const measuredHeight = | |
| measuredBox.y.max - measuredBox.y.min | |
| const scaleX = measuredWidth / offsetWidth | |
| const scaleY = measuredHeight / offsetHeight | |
| if ( | |
| Math.abs(scaleX - 1) > 0.0001 || | |
| Math.abs(scaleY - 1) > 0.0001 | |
| ) { | |
| this.cssScale = { x: scaleX, y: scaleY } | |
| } else { | |
| this.cssScale = undefined | |
| } | |
| if (offsetWidth && offsetHeight) { | |
| const { measuredBox } = this.layout | |
| const measuredWidth = | |
| measuredBox.x.max - measuredBox.x.min | |
| const measuredHeight = | |
| measuredBox.y.max - measuredBox.y.min | |
| const scaleX = measuredWidth / offsetWidth | |
| const scaleY = measuredHeight / offsetHeight | |
| if ( | |
| Math.abs(scaleX - 1) > 0.0001 || | |
| Math.abs(scaleY - 1) > 0.0001 | |
| ) { | |
| this.cssScale = { x: scaleX, y: scaleY } | |
| } else { | |
| this.cssScale = undefined | |
| } | |
| } else { | |
| this.cssScale = undefined | |
| } |
47c5e60 to
6ee0c50
Compare
Layout animations in scaled parents (e.g. style={{ scale: 2 }}) require
originX/originY as motion values — not CSS transformOrigin — so that the
projection system's removeTransform uses the correct origin. Added Cypress
E2E test demonstrating the correct usage pattern.
Fixes #3356
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
507bc0f to
23b6670
Compare
When a parent element has a CSS transform: scale() string (not motion values), the projection system was blind to the scale, causing incorrect layout animation positions. The fix parses CSS transform strings from props to extract scale and origin values, then incorporates them into resetTransform, removeTransform, applyTransform, applyTransformsToTarget, and treeScale calculations. Fixes #3356 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When a parent element has a scale transform and layoutRoot, the projection system's removeTransform() was double-removing parent transforms. During the update cycle, resetTransformStyle removes transforms from the DOM for measurement, but removeTransform then tried to mathematically undo transforms that were already gone, causing over-corrected layout boxes. The fix adds a wasTransformReset flag that tracks when a node's DOM transform was reset. removeTransform skips nodes with this flag set, since their transforms are already absent from measurements. Also includes CSS transform scale values in treeScale so that buildProjectionTransform correctly adjusts translations for parents with CSS transform: scale() strings. Fixes #3356 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Summary
transform: scale()applied alongsidelayoutRoottreeScaleonly accumulatedprojectionDeltascales (from layout animation), missing CSS-only scales entirelygetBoundingClientRectwithoffsetWidth/offsetHeightand includes them intreeScaleduring tree delta accumulationFixes #3356
How it works
When a parent has
style={{ transform: 'scale(2)' }}(not a motion value), child translations inbuildProjectionTransformare divided bytreeScaleto compensate for ancestor scaling of the coordinate space. Without this fix,treeScalewas{1, 1}for CSS-only scales, causing translations to be over-applied by the parent's scale factor (e.g. a 50px translation becomes 100px in viewport space inside ascale(2)parent).The fix:
updateLayout(), computes each node's cumulative CSS scale frommeasuredBox / offsetWidth(only for nodes without motion value scales, to avoid double-counting)applyTreeDeltas(), decomposes cumulative CSS scales into per-node contributions and multiplies them intotreeScaleTest plan
layout-scaled-parent.ts) that verifies layout animation position inside ascale(2)layoutRootparentyarn buildsucceeds🤖 Generated with Claude Code