Skip to content

Commit

Permalink
Merge pull request #2661 from framer/fix/optimized-appear
Browse files Browse the repository at this point in the history
Fix jump if optimised appear animation is interrupted
  • Loading branch information
mergetron[bot] authored May 15, 2024
2 parents c53765c + 8e5efef commit 0756e52
Show file tree
Hide file tree
Showing 6 changed files with 216 additions and 32 deletions.
156 changes: 156 additions & 0 deletions dev/optimized-appear/defer-handoff-block.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
<html>
<head>
<style>
body {
margin: 0;
}

#box {
width: 100px;
height: 100px;
background-color: #0077ff;
}

[data-layout-correct="false"] {
background: #dd1144 !important;
opacity: 1 !important;
}
</style>
</head>
<body>
<div id="root"></div>
<script src="../../node_modules/react/umd/react.development.js"></script>
<script src="../../node_modules/react-dom/umd/react-dom.development.js"></script>
<script src="../../node_modules/react-dom/umd/react-dom-server-legacy.browser.development.js"></script>
<script src="../../packages/framer-motion/dist/framer-motion.dev.js"></script>
<script src="../projection/script-assert.js"></script>

<script>
const {
motion,
animateStyle,
animate,
startOptimizedAppearAnimation,
optimizedAppearDataAttribute,
motionValue,
frame,
} = window.Motion
const { matchViewportBox } = window.Assert
const root = document.getElementById("root")

const duration = 4
const x = motionValue(0)
const xTarget = 500

let isFirstFrame = true

// This is the tree to be rendered "server" and client-side.
const Component = React.createElement(motion.div, {
id: "box",
initial: { x: 0, opacity: 0 },
animate: { x: xTarget, opacity: 1 },
transition: { duration, ease: "linear" },
style: { x },
/**
* On animation start, check the values we expect to see here
*/
onAnimationStart: () => {
const box = document.getElementById("box")

box.style.backgroundColor = "green"

setTimeout(() => {
frame.postRender(() => {
/**
* The frame visible after the infinite loop
* and before motion renders again
*/
frame.preRender(() => {
const left = box.getBoundingClientRect().left

if (left < 200) {
showError(
document.getElementById("box"),
`Stutter detected`
)
}
})
})
/**
* By blocking the main thread here, we ensure that
* the keyframes are resolved a good duration after the
* animation was initialised.
*/
frame.postRender(() => {
console.log(
"Blocking main thread before keyframe resolution"
)

const startTime = performance.now()
while (performance.now() - startTime < 1000) {}
})

/**
* This animation interrupts the optimised animation. Notably, we are animating
* x in the optimised transform animation and only scale here. This ensures
* that any transform can force the cancellation of the optimised animation on transform,
* not just those involved in the original animation.
*/
const options = { duration: 0.5, ease: "linear" }
let frameCounter = 0

box.style.backgroundColor = "red"

animate(
box,
{ scale: [1, 1] },
{
...options,
onUpdate: () => {
frameCounter++
console.log(
getComputedStyle(box).transform,
box.style.transform,
box.getBoundingClientRect().left
)
},
}
)
}, 100)
},
[optimizedAppearDataAttribute]: "a",
children: "Content",
})

// Emulate server rendering of element
root.innerHTML = ReactDOMServer.renderToString(Component)

// Start optimised opacity animation
startOptimizedAppearAnimation(
document.getElementById("box"),
"opacity",
[0, 1],
{
duration: duration * 1000,
ease: "linear",
}
)

// Start WAAPI animation
const animation = startOptimizedAppearAnimation(
document.getElementById("box"),
"transform",
["translateX(0px)", `translateX(${xTarget}px)`],
{
duration: duration * 1000,
ease: "linear",
},
(animation) => {
setTimeout(() => {
ReactDOM.hydrateRoot(root, Component)
}, (duration * 1000) / 4)
}
)
</script>
</body>
</html>
45 changes: 24 additions & 21 deletions dev/optimized-appear/defer-handoff.html
Original file line number Diff line number Diff line change
Expand Up @@ -71,29 +71,32 @@
{ scale: 2, opacity: 0.1 },
{ duration: 0.3, ease: "linear" }
).then(() => {
if (getComputedStyle(box).opacity !== "0.1") {
showError(
document.getElementById("box"),
`opacity animation didn't interrupt optimised animation. Opacity was ${
getComputedStyle(box).opacity
} instead of 0.1.`
)
}
frame.postRender(() => {
if (getComputedStyle(box).opacity !== "0.1") {
showError(
document.getElementById("box"),
`opacity animation didn't interrupt optimised animation. Opacity was ${
getComputedStyle(box).opacity
} instead of 0.1.`
)
}

const { width, left } = box.getBoundingClientRect()
if (width !== 200) {
showError(
document.getElementById("box"),
`scale animation didn't interrupt optimised animation. Width was ${width}px instead of 200px.`
)
}
const { width, left } =
box.getBoundingClientRect()
if (Math.round(width) !== 200) {
showError(
document.getElementById("box"),
`scale animation didn't interrupt optimised animation. Width was ${width}px instead of 200px.`
)
}

if (left <= 100) {
showError(
document.getElementById("box"),
`scale animation incorrectly interrupted optimised animation. Left was ${left}px instead of 100px.`
)
}
if (left <= 100) {
showError(
document.getElementById("box"),
`scale animation incorrectly interrupted optimised animation. Left was ${left}px instead of 100px.`
)
}
})
})
}, 100)
},
Expand Down
2 changes: 1 addition & 1 deletion packages/framer-motion/cypress/fixtures/appear-tests.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
["defer-handoff-layout.html","defer-handoff.html","interrupt-delay-after.html","interrupt-delay-before-accelerated.html","interrupt-delay-before.html","interrupt-spring.html","interrupt-tween-opacity-waapi.html","interrupt-tween-opacity.html","interrupt-tween-transforms.html","interrupt-tween-x.html","persist-optimised-animation.html","persist.html","portal.html","resync-delay.html","resync.html","start-after-hydration.html"]
["defer-handoff-block.html","defer-handoff-layout.html","defer-handoff.html","interrupt-delay-after.html","interrupt-delay-before-accelerated.html","interrupt-delay-before.html","interrupt-spring.html","interrupt-tween-opacity-waapi.html","interrupt-tween-opacity.html","interrupt-tween-transforms.html","interrupt-tween-x.html","persist-optimised-animation.html","persist.html","portal.html","resync-delay.html","resync.html","start-after-hydration.html"]
Original file line number Diff line number Diff line change
Expand Up @@ -78,11 +78,15 @@ export function animateTarget(
let isHandoff = false
if (window.HandoffAppearAnimations) {
const props = visualElement.getProps()
const appearId =
props[optimizedAppearDataAttribute]
const appearId = props[optimizedAppearDataAttribute]

if (appearId) {
const elapsed = window.HandoffAppearAnimations(appearId, key)
const elapsed = window.HandoffAppearAnimations(
appearId,
key,
value,
frame
)

if (elapsed !== null) {
valueTransition.elapsed = elapsed
Expand Down
31 changes: 26 additions & 5 deletions packages/framer-motion/src/animation/optimized-appear/handoff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ export function handoffOptimizedAppearAnimation(
*
* Remove in early 2024.
*/
_value: MotionValue,
_frame: Batcher
_value?: MotionValue,
frame?: Batcher
): number | null {
const optimisedValueName = transformProps.has(valueName)
? "transform"
Expand All @@ -34,9 +34,30 @@ export function handoffOptimizedAppearAnimation(
const cancelAnimation = () => {
appearAnimationStore.delete(storeId)

try {
animation.cancel()
} catch (error) {}
if (frame) {
/**
* If we've been provided the frameloop as an argument, use it to defer
* cancellation until keyframes of the subsequent animation have been resolved.
* This "papers over" a gap where the JS animations haven't rendered with
* the latest time after a potential heavy blocking workload.
* Otherwise cancel immediately.
*
* This is an optional dependency to deal with the fact that this inline
* script and the library can be version sharded, and there have been
* times when this isn't provided as an argument.
*/
frame.render(() =>
frame.render(() => {
try {
animation.cancel()
} catch (error) {}
})
)
} else {
try {
animation.cancel()
} catch (error) {}
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import type { MotionValue } from "../../value"
export type HandoffFunction = (
storeId: string,
valueName: string,
_value?: MotionValue,
_frame?: Batcher
value?: MotionValue,
frame?: Batcher
) => null | number

/**
Expand Down

0 comments on commit 0756e52

Please sign in to comment.