Skip to content

Commit

Permalink
Merge pull request #2706 from framer/feature/react-19-code
Browse files Browse the repository at this point in the history
Fix animations in re-suspended components
  • Loading branch information
mergetron[bot] authored Jun 19, 2024
2 parents be06a73 + 8540940 commit aa26805
Show file tree
Hide file tree
Showing 15 changed files with 218 additions and 168 deletions.
1 change: 1 addition & 0 deletions dev/react/src/tests/drag-tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,7 @@ li {
}
li span {
color: black;
flex-shrink: 1;
flex-grow: 1;
white-space: nowrap;
Expand Down
4 changes: 2 additions & 2 deletions dev/react/src/tests/layout-shared-crossfade-nested.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { motion, AnimatePresence } from "framer-motion"
import { useState } from "react";
import { useState } from "react"

const transition = { duration: 0.2, ease: () => 0.5 }
const transition = { duration: 1, ease: () => 0.5 }
export const App = () => {
const params = new URLSearchParams(window.location.search)
const type = params.get("type") || true
Expand Down
2 changes: 1 addition & 1 deletion packages/framer-motion/cypress/integration/drag-tabs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ describe("Tabs demo", () => {
cy.visit("?test=drag-tabs")
.get("#Tomato-remove")
.click()
.wait(150)
.wait(400)
.get("#Lettuce-tab")
.trigger("pointerdown", 40, 10)
.wait(30)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -872,7 +872,6 @@ describe("Shared layout: nested crossfade transition", () => {
.get("#a")
.trigger("click")
.wait(50)
.get("#a")
.should(([$box]: any) => {
expectBbox($box, {
top: 200,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -589,7 +589,7 @@ export class VisualElementDragControls {

const measureDragConstraints = () => {
const { dragConstraints } = this.getProps()
if (isRefObject(dragConstraints)) {
if (isRefObject(dragConstraints) && dragConstraints.current) {
this.constraints = this.resolveRefConstraints()
}
}
Expand All @@ -606,7 +606,7 @@ export class VisualElementDragControls {
projection.updateLayout()
}

measureDragConstraints()
frame.read(measureDragConstraints)

/**
* Attach a window resize listener to scale the draggable target within its defined
Expand Down
21 changes: 12 additions & 9 deletions packages/framer-motion/src/motion/__tests__/ssr.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,18 @@ function runTests(render: (components: any) => string) {
function Component() {
const ref = useRef<HTMLDivElement>(null)
return (
<motion.div
ref={ref}
initial={{ x: 100 }}
whileTap={{ opacity: 0 }}
drag
layout
layoutId="a"
style={{ opacity: 1 }}
/>
<>
<motion.div
ref={ref}
initial={{ x: 100 }}
whileTap={{ opacity: 0 }}
drag
layout
layoutId="a"
style={{ opacity: 1 }}
/>
<motion.button disabled />
</>
)
}
render(<Component />)
Expand Down
10 changes: 7 additions & 3 deletions packages/framer-motion/src/motion/features/animation/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { VisualElement } from "../../../render/VisualElement"
import { Feature } from "../Feature"

export class AnimationFeature extends Feature<unknown> {
unmountControls?: () => void

/**
* We dynamically generate the AnimationState manager as it contains a reference
* to the underlying animation library. We only want to load that if we load this,
Expand All @@ -16,9 +18,8 @@ export class AnimationFeature extends Feature<unknown> {

updateAnimationControlsSubscription() {
const { animate } = this.node.getProps()
this.unmount()
if (isAnimationControls(animate)) {
this.unmount = animate.subscribe(this.node)
this.unmountControls = animate.subscribe(this.node)
}
}

Expand All @@ -37,5 +38,8 @@ export class AnimationFeature extends Feature<unknown> {
}
}

unmount() {}
unmount() {
this.node.animationState!.reset()
this.unmountControls?.()
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { frame } from "../../../frameloop"
import { Component, useContext } from "react";
import { Component, useContext } from "react"
import { usePresence } from "../../../components/AnimatePresence/use-presence"
import {
LayoutGroupContext,
Expand Down
2 changes: 1 addition & 1 deletion packages/framer-motion/src/motion/features/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export type FeatureDefinition = {
}

export type FeatureDefinitions = {
[K in keyof HydratedFeatureDefinition]: FeatureDefinition
[K in keyof HydratedFeatureDefinitions]: FeatureDefinition
}

export type FeaturePackage = {
Expand Down
69 changes: 48 additions & 21 deletions packages/framer-motion/src/motion/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,10 @@ import { loadFeatures } from "./features/load-features"
import { isBrowser } from "../utils/is-browser"
import { LayoutGroupContext } from "../context/LayoutGroupContext"
import { LazyContext } from "../context/LazyContext"
import { SwitchLayoutGroupContext } from "../context/SwitchLayoutGroupContext"
import { motionComponentSymbol } from "./utils/symbol"
import { CreateVisualElement } from "../render/types"
import { invariant, warning } from "../utils/errors"
import { featureDefinitions } from "./features/definitions"

export interface MotionComponentConfig<Instance, RenderState> {
preloadedFeatures?: FeatureBundle
Expand Down Expand Up @@ -65,6 +66,11 @@ export function createMotionComponent<Props extends {}, Instance, RenderState>({
const visualState = useVisualState(props, isStatic)

if (!isStatic && isBrowser) {
useStrictMode(configAndProps, preloadedFeatures)

const layoutProjection = getProjectionFunctionality(configAndProps)
MeasureLayout = layoutProjection.MeasureLayout

/**
* Create a VisualElement for this component. A VisualElement provides a common
* interface to renderer-specific APIs (ie DOM/Three.js etc) as well as
Expand All @@ -75,27 +81,9 @@ export function createMotionComponent<Props extends {}, Instance, RenderState>({
Component,
visualState,
configAndProps,
createVisualElement
createVisualElement,
layoutProjection.ProjectionNode
)

/**
* Load Motion gesture and animation features. These are rendered as renderless
* components so each feature can optionally make use of React lifecycle methods.
*/
const initialLayoutGroupConfig = useContext(
SwitchLayoutGroupContext
)
const isStrict = useContext(LazyContext).strict

if (context.visualElement) {
MeasureLayout = context.visualElement.loadFeatures(
// Note: Pass the full new combined props to correctly re-render dynamic feature components.
configAndProps,
isStrict,
preloadedFeatures,
initialLayoutGroupConfig
)
}
}

/**
Expand Down Expand Up @@ -137,3 +125,42 @@ function useLayoutId({ layoutId }: MotionProps) {
? layoutGroupId + "-" + layoutId
: layoutId
}

function useStrictMode(
configAndProps: MotionProps,
preloadedFeatures?: FeatureBundle
) {
const isStrict = useContext(LazyContext).strict

/**
* If we're in development mode, check to make sure we're not rendering a motion component
* as a child of LazyMotion, as this will break the file-size benefits of using it.
*/
if (
process.env.NODE_ENV !== "production" &&
preloadedFeatures &&
isStrict
) {
const strictMessage =
"You have rendered a `motion` component within a `LazyMotion` component. This will break tree shaking. Import and render a `m` component instead."
configAndProps.ignoreStrict
? warning(false, strictMessage)
: invariant(false, strictMessage)
}
}

function getProjectionFunctionality(props: MotionProps) {
const { drag, layout } = featureDefinitions

if (!drag && !layout) return {}

const combined = { ...drag, ...layout }

return {
MeasureLayout:
drag?.isEnabled(props) || layout?.isEnabled(props)
? combined.MeasureLayout
: undefined,
ProjectionNode: combined.ProjectionNode,
}
}
8 changes: 5 additions & 3 deletions packages/framer-motion/src/motion/utils/use-motion-ref.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,11 @@ export function useMotionRef<Instance, RenderState>(
instance && visualState.mount && visualState.mount(instance)

if (visualElement) {
instance
? visualElement.mount(instance)
: visualElement.unmount()
if (instance) {
visualElement.mount(instance)
} else {
visualElement.unmount()
}
}

if (externalRef) {
Expand Down
101 changes: 97 additions & 4 deletions packages/framer-motion/src/motion/utils/use-visual-element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,21 @@ import { MotionConfigContext } from "../../context/MotionConfigContext"
import type { VisualElement } from "../../render/VisualElement"
import { optimizedAppearDataAttribute } from "../../animation/optimized-appear/data-id"
import { microtask } from "../../frameloop/microtask"
import { IProjectionNode } from "../../projection/node/types"
import { isRefObject } from "../../utils/is-ref-object"
import {
InitialPromotionConfig,
SwitchLayoutGroupContext,
} from "../../context/SwitchLayoutGroupContext"

let scheduleHandoffComplete = false

export function useVisualElement<Instance, RenderState>(
Component: string | React.ComponentType<React.PropsWithChildren<unknown>>,
visualState: VisualState<Instance, RenderState>,
props: MotionProps & Partial<MotionConfigContext>,
createVisualElement?: CreateVisualElement<Instance>
createVisualElement?: CreateVisualElement<Instance>,
ProjectionNodeConstructor?: any
): VisualElement<Instance> | undefined {
const { visualElement: parent } = useContext(MotionContext)
const lazyContext = useContext(LazyContext)
Expand Down Expand Up @@ -45,6 +54,26 @@ export function useVisualElement<Instance, RenderState>(

const visualElement = visualElementRef.current

/**
* Load Motion gesture and animation features. These are rendered as renderless
* components so each feature can optionally make use of React lifecycle methods.
*/
const initialLayoutGroupConfig = useContext(SwitchLayoutGroupContext)

if (
visualElement &&
!visualElement.projection &&
ProjectionNodeConstructor &&
(visualElement.type === "html" || visualElement.type === "svg")
) {
createProjectionNode(
visualElementRef.current!,
props,
ProjectionNodeConstructor,
initialLayoutGroupConfig
)
}

useInsertionEffect(() => {
visualElement && visualElement.update(props, presenceContext)
})
Expand All @@ -63,6 +92,8 @@ export function useVisualElement<Instance, RenderState>(
useIsomorphicLayoutEffect(() => {
if (!visualElement) return

visualElement.updateFeatures()

microtask.render(visualElement.render)

/**
Expand All @@ -83,18 +114,80 @@ export function useVisualElement<Instance, RenderState>(
useEffect(() => {
if (!visualElement) return

visualElement.updateFeatures()

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

if (wantsHandoff.current) {
wantsHandoff.current = false
// This ensures all future calls to animateChanges() will run in useEffect
window.HandoffComplete = true
if (!scheduleHandoffComplete) {
scheduleHandoffComplete = true
queueMicrotask(completeHandoff)
}
}
})

return visualElement
}

function completeHandoff() {
window.HandoffComplete = true
}

function createProjectionNode(
visualElement: VisualElement<any>,
props: MotionProps,
ProjectionNodeConstructor: any,
initialPromotionConfig?: InitialPromotionConfig
) {
const {
layoutId,
layout,
drag,
dragConstraints,
layoutScroll,
layoutRoot,
} = props

visualElement.projection = new ProjectionNodeConstructor(
visualElement.latestValues,
props["data-framer-portal-id"]
? undefined
: getClosestProjectingNode(visualElement.parent)
) as IProjectionNode

visualElement.projection.setOptions({
layoutId,
layout,
alwaysMeasureLayout:
Boolean(drag) || (dragConstraints && isRefObject(dragConstraints)),
visualElement,
scheduleRender: () => visualElement.scheduleRender(),
/**
* TODO: Update options in an effect. This could be tricky as it'll be too late
* to update by the time layout animations run.
* We also need to fix this safeToRemove by linking it up to the one returned by usePresence,
* ensuring it gets called if there's no potential layout animations.
*
*/
animationType: typeof layout === "string" ? layout : "both",
initialPromotionConfig,
layoutScroll,
layoutRoot,
})
}

function getClosestProjectingNode(
visualElement?: VisualElement<
unknown,
unknown,
{ allowProjection?: boolean }
>
): IProjectionNode | undefined {
if (!visualElement) return undefined

return visualElement.options.allowProjection !== false
? visualElement.projection
: getClosestProjectingNode(visualElement.parent)
}
Original file line number Diff line number Diff line change
Expand Up @@ -726,10 +726,12 @@ export function createProjectionNode<I>({
frameData.isProcessing = false
}

scheduleUpdate = () => this.update()

didUpdate() {
if (!this.updateScheduled) {
this.updateScheduled = true
microtask.read(() => this.update())
microtask.read(this.scheduleUpdate)
}
}

Expand Down
Loading

0 comments on commit aa26805

Please sign in to comment.