Fix layout animation bouncing when child size changes with explicit duration#3640
Fix layout animation bouncing when child size changes with explicit duration#3640mattgperry wants to merge 1 commit intomainfrom
Conversation
When a parent layout element has an explicit transition duration and a child also has layout, the child would use the default layout transition (0.45s) while the parent used the explicit duration. This timing mismatch caused the child's relative position to interpolate at a different rate than the parent's size, resulting in bouncing. Two changes: 1. Child layout animations now inherit the closest projecting parent's transition when they don't have their own explicit transition. 2. Transition clearing is deferred to a separate pass after all layout animations start, so children can read the parent's transition. Fixes #3028 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Greptile SummaryThis PR fixes layout animation bouncing (issue #3028) that occurred when a parent element had an explicit
The fix is logically correct for the fresh-start animation case: Confidence Score: 4/5
Important Files Changed
Sequence DiagramsequenceDiagram
participant Root as Root Node
participant FlatTree as FlatTree (depth-sorted)
participant Parent as Parent ProjectionNode
participant Child as Child ProjectionNode
participant Frame as frame.update (next tick)
Root->>FlatTree: forEach(notifyLayoutUpdate)
FlatTree->>Parent: notifyLayoutUpdate(parent)
Note over Parent: Reads options.transition → {duration:1}<br/>Fires "didUpdate" listener<br/>Calls startAnimation() — deferred to frame.update
Parent-->>FlatTree: done (options.transition still set)
FlatTree->>Child: notifyLayoutUpdate(child)
Note over Child: options.transition → undefined<br/>getDefaultTransition() → undefined<br/>getClosestProjectingParent()?.options.transition → {duration:1} ✓<br/>Fires "didUpdate" listener<br/>Calls startAnimation() with duration:1
Child-->>FlatTree: done
Root->>FlatTree: forEach(clearTransition) [NEW separate pass]
FlatTree->>Parent: node.options.transition = undefined
FlatTree->>Child: node.options.transition = undefined
Root->>Frame: frameSteps.update.process() — animations begin<br/>latestValues updated here (after both nodes set up)
Last reviewed commit: 35c8a01 |
| this.getClosestProjectingParent()?.options | ||
| .transition || |
There was a problem hiding this comment.
Parent transition inheritance silently breaks for mid-animation re-triggers
getClosestProjectingParent() returns undefined when the closest projecting parent has a non-identity scale, scaleX, scaleY, x, or y in latestValues (lines 1318–1319). During the notifyLayoutUpdate pass, startAnimation defers the actual animation to frame.update, so latestValues is safe for a fresh-start trigger. However, if the user re-triggers the layout animation while one is already in progress, the parent node will already have non-identity scale/translate in latestValues, causing getClosestProjectingParent() to return undefined and silently falling through to defaultLayoutTransition instead of the parent's transition.
This is not a regression (the original code had the same limitation), but given that the PR description frames this as a general fix for duration synchronisation, it may be worth noting this edge case in a comment so a future reader understands the boundary condition.
| this.getClosestProjectingParent()?.options | |
| .transition || | |
| this.options.transition || | |
| visualElement.getDefaultTransition() || | |
| // Inherits the closest projecting parent's transition so parent and | |
| // child layout animations stay synchronised. Note: this fallback is | |
| // skipped when the parent already has non-identity scale/translate in | |
| // latestValues (e.g. during a mid-animation re-trigger), because | |
| // getClosestProjectingParent returns undefined in that case. | |
| this.getClosestProjectingParent()?.options | |
| .transition || | |
| defaultLayoutTransition |
| // Wait for full animation to complete (parent: 1s) | ||
| .wait(1200) | ||
| .get("#tracker") |
There was a problem hiding this comment.
.wait(50) before click may be insufficient on slow CI runners
The 50 ms wait is intended to let the page settle before triggering the animation. On slower CI machines or when the test browser takes longer to mount React, 50 ms may not be enough, making the test flaky. The existing layout tests in this repo typically use a #trigger element or wait for a specific DOM condition rather than an arbitrary delay. Consider waiting for the #parent element to be visible instead:
| // Wait for full animation to complete (parent: 1s) | |
| .wait(1200) | |
| .get("#tracker") | |
| cy.visit("?test=layout-child-scale-correction-duration") | |
| .get("#parent") | |
| .should("be.visible") | |
| .trigger("click") |
Summary
transitionduration and a child withlayoutalso changes sizeBug
When
transition={{ duration: 1 }}is set on a parent layout element and a child withlayoutchanges size, the child uses the default layout transition (0.45s) while the parent uses 1s. This timing mismatch causes the child's relative position to interpolate at a different rate than the parent's size changes, producing visible bouncing/jittering.Root cause
In
notifyLayoutUpdate, each node resolves its layout transition independently:The child's
relativeTarget(its position within the parent) is mixed using the child's own animation progress. When the child's animation finishes at 0.45s while the parent is still at ~45% progress, the child's absolute coordinates no longer correspond to the correct proportional position within the parent's intermediate size.Additionally, parent transitions were cleared in
notifyLayoutUpdatebefore children could read them, since nodes are processed depth-first.Fix
clearTransitionpass that runs after allnotifyLayoutUpdatecallbacksFixes #3028
Test plan
layout-child-scale-correction-duration) that records min/max child dimensions during animation and verifies they stay within bounds🤖 Generated with Claude Code