Skip to content

Commit

Permalink
Track appear effect handover per element due to Suspense (#2810)
Browse files Browse the repository at this point in the history
* Remove `scheduleHandoffComplete` due to Suspense

With nested Suspense boundaries, it might be that any children mount at a later timing than `queueMicrotask` guarantees. While we could switch to another timing-based method, it doesn't reliably ensure our call happens later than the mounting of the optimized appear effect. There seems to be no reliable way of telling when those nested boundaries have mounted (after completing) hydration, so removing the functionality seems to be the most sensible way. We remove optimized appear animations from the appearAnimationStore anyway after running, so this was just a minor optimization.
The trade-off might be, appear effects re-run when changing breakpoints, but to me that sounds ok, as barely any user ever changes the breakpoint.

* store handover state per appear element Id

* add test

---------

Co-authored-by: Matt Perry <mattgperry@gmail.com>
  • Loading branch information
kurtextrem and mattgperry authored Oct 4, 2024
1 parent 25b0e5a commit 7eb6728
Show file tree
Hide file tree
Showing 6 changed files with 215 additions and 20 deletions.
184 changes: 184 additions & 0 deletions dev/html/public/optimized-appear/defer-handoff-suspense.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
<html>
<head>
<style>
body {
padding: 100px;
margin: 0;
}

#box1,
#box2 {
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 type="module" src="/src/imports/optimized-appear.js"></script>
<script type="module" src="/src/imports/script-assert.js"></script>

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

const duration = 2
const x1 = motionValue(0)
const x2 = motionValue(0)

let suspendPromise = false
const SuspendingComponent = ({ children }) => {
if (suspendPromise === undefined) {
suspendPromise = new Promise((resolve) => {
setTimeout(() => {
suspendPromise = true
resolve()
}, 10)
})
throw suspendPromise
}
return children
}

const getDiv = (i, children) =>
React.createElement(motion.div, {
id: "box" + i,
initial: { x: 0, opacity: 0 },
animate: { x: 100, opacity: 1 },
transition: { duration, ease: "linear" },
style: { x: i === 1 ? x1 : x2 },
/**
* On animation start, check the values we expect to see here
*/
onAnimationStart: () => {
console.log("start", i)
const box = document.getElementById("box" + i)

box.style.backgroundColor = i === 1 ? "green" : "blue"

setTimeout(() => {
/**
* 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.
*/
animate(
box,
{ scale: 2, opacity: 0.1 },
{ duration: 0.3, ease: "linear" }
).then(() => {
frame.postRender(() => {
const style = getComputedStyle(box)
if (style.opacity !== "0.1") {
showError(
box,
`${i} opacity animation didn't interrupt optimised animation. Opacity was ${style.opacity} instead of 0.1.`
)
}

const { width, left } =
box.getBoundingClientRect()
// the width scales with the parent, e.g. if the parent scales 2x, the child scales scales 2x on top
if (Math.round(width) !== 200 * i) {
showError(
box,
`${i} scale animation didn't interrupt optimised animation. Width was ${width}px instead of ${
200 * i
}px.`
)
}

if (left <= 100) {
showError(
box,
`${i} scale animation incorrectly interrupted optimised animation. Left was ${left}px instead of 100px.`
)
}
})
})
}, 100)
},
[optimizedAppearDataAttribute]: "a" + i,
children: children ?? "Content",
})

// This is the tree to be rendered "server" and client-side.
const Component = getDiv(
1,
React.createElement(
React.Suspense,
{},
React.createElement(SuspendingComponent, {}, getDiv(2))
)
)

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

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

// Start WAAPI animation
startOptimizedAppearAnimation(
document.getElementById("box1"),
"transform",
["translateX(0px)", "translateX(100px)"],
{
duration: duration * 1000,
ease: "linear",
},
(animation) => {}
)

// Start optimised opacity animation
startOptimizedAppearAnimation(
document.getElementById("box2"),
"opacity",
[0, 1],
{
duration: duration * 1000,
ease: "linear",
}
)
startOptimizedAppearAnimation(
document.getElementById("box2"),
"transform",
["translateX(0px)", "translateX(100px)"],
{
duration: duration * 1000,
ease: "linear",
},
(animation) => {
setTimeout(() => {
ReactDOMClient.hydrateRoot(root, Component)
}, (duration * 1000) / 2)
}
)
</script>
</body>
</html>
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,10 @@ export function handoffOptimizedAppearAnimation(
*/
animation.onfinish = cancelAnimation

if (startTime === null || window.MotionHandoffIsComplete) {
if (
startTime === null ||
window.MotionOptimisedAnimationHandedover?.(elementId)
) {
/**
* If the startTime is null, this animation is the Paint Ready detection animation
* and we can cancel it immediately without handoff.
Expand Down
18 changes: 14 additions & 4 deletions packages/framer-motion/src/animation/optimized-appear/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,13 +52,11 @@ export function startOptimizedAppearAnimation(
onReady?: (animation: Animation) => void
): void {
// Prevent optimised appear animations if Motion has already started animating.
if (window.MotionHandoffIsComplete) {
window.MotionHandoffAnimation = undefined
if (window.MotionOptimisedAnimationHandedover?.(optimizedAppearDataId)) {
return
}

const id = element.dataset[optimizedAppearDataId]

if (!id) return

window.MotionHandoffAnimation = handoffOptimizedAppearAnimation
Expand Down Expand Up @@ -110,6 +108,18 @@ export function startOptimizedAppearAnimation(
return Boolean(appearAnimationStore.get(animationId))
}

window.MotionOptimisedAnimationHandoff = (elementId: string): void => {
if (elementsWithAppearAnimations.has(elementId)) {
elementsWithAppearAnimations.set(elementId, true)
}
}

window.MotionOptimisedAnimationHandedover = (
elementId: string
): boolean => {
return elementsWithAppearAnimations.get(elementId) === true
}

/**
* We only need to cancel transform animations as
* they're the ones that will interfere with the
Expand Down Expand Up @@ -221,7 +231,7 @@ export function startOptimizedAppearAnimation(
if (onReady) onReady(appearAnimation)
}

elementsWithAppearAnimations.add(id)
elementsWithAppearAnimations.set(id, false)

if (readyAnimation.ready) {
readyAnimation.ready.then(startAnimation).catch(noop)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,4 @@ export interface AppearStoreEntry {

export const appearAnimationStore = new Map<string, AppearStoreEntry>()

export const elementsWithAppearAnimations = new Set<string>()
export const elementsWithAppearAnimations = new Map<string, boolean>() // optimisedAppearId, handedOff
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ export type HandoffFunction = (
declare global {
interface Window {
MotionHandoffAnimation?: HandoffFunction
MotionHandoffIsComplete?: boolean
MotionOptimisedAnimationHandoff?: (elementId: string) => void
MotionOptimisedAnimationHandedover?: (elementId: string) => boolean
MotionHasOptimisedAnimation?: (
elementId?: string,
valueName?: string
Expand Down
23 changes: 10 additions & 13 deletions packages/framer-motion/src/motion/utils/use-visual-element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,6 @@ import {
SwitchLayoutGroupContext,
} from "../../context/SwitchLayoutGroupContext"

let scheduleHandoffComplete = false

export function useVisualElement<Instance, RenderState>(
Component: string | React.ComponentType<React.PropsWithChildren<unknown>>,
visualState: VisualState<Instance, RenderState>,
Expand Down Expand Up @@ -86,7 +84,7 @@ export function useVisualElement<Instance, RenderState>(
props[optimizedAppearDataAttribute as keyof typeof props]
const wantsHandoff = useRef(
Boolean(optimisedAppearId) &&
!window.MotionHandoffIsComplete &&
!window.MotionOptimisedAnimationHandedover?.(optimisedAppearId) &&
window.MotionHasOptimisedAnimation?.(optimisedAppearId)
)

Expand Down Expand Up @@ -115,25 +113,24 @@ export function useVisualElement<Instance, RenderState>(
useEffect(() => {
if (!visualElement) return

if (!wantsHandoff.current && visualElement.animationState) {
const handoffNeeded = wantsHandoff.current
if (!handoffNeeded && visualElement.animationState) {
visualElement.animationState.animateChanges()
}

wantsHandoff.current = false
// This ensures all future calls to animateChanges() will run in useEffect
if (!scheduleHandoffComplete) {
scheduleHandoffComplete = true
queueMicrotask(completeHandoff)
if (handoffNeeded) {
// This ensures all future calls to animateChanges() in this component will run in useEffect
queueMicrotask(() =>
window.MotionOptimisedAnimationHandoff?.(optimisedAppearId)
)
}

wantsHandoff.current = false
})

return visualElement
}

function completeHandoff() {
window.MotionHandoffIsComplete = true
}

function createProjectionNode(
visualElement: VisualElement<any>,
props: MotionProps,
Expand Down

0 comments on commit 7eb6728

Please sign in to comment.