Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
175 changes: 175 additions & 0 deletions dev/html/public/benchmarks/transform-rebuild-overhead.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
<html>
<!--
Transform Rebuild Overhead Benchmark

This benchmark measures the overhead of rebuilding transforms when
animating non-transform properties. In the old renderer, animating
backgroundColor would trigger a full transform string rebuild.
With atomic updates, only backgroundColor would be updated.

Phase 1: Animate x, y, scale, rotate, backgroundColor on 200 divs
Phase 2 (after 1s): Animate only backgroundColor

In the old renderer, Phase 2 still rebuilds transform strings.
With atomic updates, Phase 2 only touches backgroundColor.
-->
<head>
<style>
body {
padding: 0;
margin: 0;
font-family: system-ui, sans-serif;
}

.stats {
position: fixed;
top: 10px;
left: 10px;
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 15px;
border-radius: 8px;
font-size: 14px;
z-index: 1000;
}

.stats div {
margin: 5px 0;
}

.container {
padding: 20px;
width: 100%;
display: flex;
flex-wrap: wrap;
gap: 5px;
}

.box {
width: 40px;
height: 40px;
background-color: #ff0000;
border-radius: 4px;
}
</style>
</head>
<body>
<div class="stats">
<div>Phase: <span id="phase">Initializing...</span></div>
<div>Boxes: <span id="boxCount">0</span></div>
<div>FPS: <span id="fps">--</span></div>
<div>Avg Frame Time: <span id="frameTime">--</span>ms</div>
</div>
<div class="container"></div>
<script type="module" src="/src/imports/framer-motion-dom.js"></script>
<script type="module">
const { animate } = window.Motion

const NUM_BOXES = 200
const PHASE1_DURATION = 1000 // 1 second
const PHASE2_DURATION = 2000 // 2 seconds

// Create boxes
let html = ``
for (let i = 0; i < NUM_BOXES; i++) {
html += `<div class="box"></div>`
}
document.querySelector(".container").innerHTML = html
const boxes = document.querySelectorAll(".box")
document.getElementById("boxCount").textContent = NUM_BOXES

// FPS tracking
let frameCount = 0
let frameTimes = []
let lastTime = performance.now()

function trackFrame() {
const now = performance.now()
const delta = now - lastTime
lastTime = now
frameCount++
frameTimes.push(delta)

// Keep last 60 frames
if (frameTimes.length > 60) {
frameTimes.shift()
}

// Update stats every 10 frames
if (frameCount % 10 === 0) {
const avgFrameTime = frameTimes.reduce((a, b) => a + b, 0) / frameTimes.length
const fps = 1000 / avgFrameTime
document.getElementById("fps").textContent = fps.toFixed(1)
document.getElementById("frameTime").textContent = avgFrameTime.toFixed(2)
}

requestAnimationFrame(trackFrame)
}

// Start FPS tracking
requestAnimationFrame(trackFrame)

// Phase 1: Animate all properties
document.getElementById("phase").textContent = "Phase 1: All properties (x, y, scale, rotate, backgroundColor)"

const phase1Animations = []
boxes.forEach((box, i) => {
// Stagger the animations slightly for visual interest
const delay = (i % 20) * 10

phase1Animations.push(
animate(
box,
{
x: [0, 50, 0],
y: [0, 30, 0],
scale: [1, 1.2, 1],
rotate: [0, 180, 360],
backgroundColor: ["#ff0000", "#00ff00", "#0000ff", "#ff0000"],
},
{
duration: PHASE1_DURATION / 1000,
delay: delay / 1000,
ease: "easeInOut",
}
)
)
})

// Phase 2: After 1 second, animate ONLY backgroundColor
// This is where the overhead difference shows:
// - Old renderer: rebuilds transform string on every backgroundColor change
// - Atomic updates: only updates backgroundColor style property
setTimeout(() => {
document.getElementById("phase").textContent = "Phase 2: Only backgroundColor (transform overhead test)"

// Reset frame tracking for phase 2
frameTimes = []
frameCount = 0

boxes.forEach((box, i) => {
const delay = (i % 20) * 5

animate(
box,
{
backgroundColor: [
"#0000ff",
"#ff00ff",
"#00ffff",
"#ffff00",
"#ff0000",
],
},
{
duration: PHASE2_DURATION / 1000,
delay: delay / 1000,
ease: "linear",
repeat: Infinity,
}
)
})
}, PHASE1_DURATION + 500)
</script>
</body>
</html>
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ class StateVisualElement extends VisualElement<
getBaseTargetFromProps() {
return undefined
}
createMotionValueState() {
// Return undefined to use legacy bindToMotionValue path
return undefined
}

readValueFromInstance(
_state: ResolvedValues,
Expand Down
35 changes: 34 additions & 1 deletion packages/framer-motion/src/render/VisualElement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
KeyframeResolver,
microtask,
motionValue,
MotionValueState,
time,
transformProps,
type AnyResolvedKeyframe,
Expand Down Expand Up @@ -143,6 +144,13 @@ export abstract class VisualElement<
projection?: IProjectionNode
): void

/**
* Create the MotionValueState for this visual element type.
* Subclasses should implement this to provide atomic rendering behavior.
* Return undefined to use the legacy bindToMotionValue path.
*/
abstract createMotionValueState(): MotionValueState | undefined

/**
* This method is called when a transform property is bound to a motion value.
* It's currently used to measure SVG elements when a new transform property is bound.
Expand Down Expand Up @@ -254,10 +262,18 @@ export abstract class VisualElement<
*/
projection?: IProjectionNode

/**
* The MotionValueState that manages motion values for this visual element.
* Created on mount via createMotionValueState(). May be undefined if the
* subclass returns undefined to use the legacy binding path.
*/
protected state?: MotionValueState

/**
* A map of all motion values attached to this visual element. Motion
* values are source of truth for any given animated value. A motion
* value might be provided externally by the component via props.
* @deprecated Use state.get() instead
*/
values = new Map<string, MotionValue>()

Expand Down Expand Up @@ -431,7 +447,13 @@ export abstract class VisualElement<

this.parent?.addChild(this)

// Call update before creating state so initial values use bindToMotionValue
// This ensures backward-compatible onUpdate behavior for initial values
this.update(this.props, this.presenceContext)

// Create the state AFTER initial update - only NEW values from subsequent
// updates will use bindToState for atomic updates
this.state = this.createMotionValueState()
}

unmount() {
Expand All @@ -440,6 +462,7 @@ export abstract class VisualElement<
cancelFrame(this.render)
this.valueSubscriptions.forEach((remove) => remove())
this.valueSubscriptions.clear()
this.state?.destroy()
this.removeFromVariantTree && this.removeFromVariantTree()
this.parent?.removeChild(this)

Expand Down Expand Up @@ -502,7 +525,8 @@ export abstract class VisualElement<
this.valueSubscriptions.set(key, () => {
removeOnChange()
if (removeSyncCheck) removeSyncCheck()
if (value.owner) value.stop()
// Note: value.stop() is NOT called here - it's handled in removeValue()
// This allows rebinding without stopping owned values
})
}

Expand Down Expand Up @@ -699,7 +723,11 @@ export abstract class VisualElement<

if (value !== existingValue) {
if (existingValue) this.removeValue(key)

// Always use bindToMotionValue for backward compatibility
// This ensures onUpdate works correctly via the change listener path
this.bindToMotionValue(key, value)

this.values.set(key, value)
this.latestValues[key] = value.get()
}
Expand All @@ -709,12 +737,17 @@ export abstract class VisualElement<
* Remove a motion value and unbind any active subscriptions.
*/
removeValue(key: string) {
const value = this.values.get(key)
this.values.delete(key)
const unsubscribe = this.valueSubscriptions.get(key)
if (unsubscribe) {
unsubscribe()
this.valueSubscriptions.delete(key)
}
// Stop owned values when truly removing (not just rebinding)
if (value?.owner) {
value.stop()
}
delete this.latestValues[key]
this.removeValueFromRenderState(key, this.renderState)
}
Expand Down
48 changes: 48 additions & 0 deletions packages/framer-motion/src/render/html/HTMLVisualElement.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import {
AnyResolvedKeyframe,
defaultTransformValue,
frame,
HTMLMotionValueState,
isCSSVariableName,
readTransformValue,
transformProps,
Expand Down Expand Up @@ -29,6 +31,52 @@ export class HTMLVisualElement extends DOMVisualElement<
> {
type = "html"

createMotionValueState(): HTMLMotionValueState {
return new HTMLMotionValueState({
element: this.current,
getTransformTemplate: () => this.props.transformTemplate,
onTransformChange: () => {
if (this.projection) {
this.projection.isTransformDirty = true
}
},
onUpdate: () => {
if (this.props.onUpdate) {
// Use postRender so onUpdate fires in the current frame
// (render callbacks run in render phase, preRender would go to next frame)
frame.postRender(this.notifyUpdate)
}
},
onValueChange: (key, value) => {
// Skip internal computed values (transform, transformOrigin)
// These are internal to the state and shouldn't sync to latestValues
if (key === "transform" || key === "transformOrigin") {
return
}

// Skip if value hasn't actually changed - prevents duplicate onUpdate calls
if (this.latestValues[key] === value) {
return
}

// Sync to latestValues
this.latestValues[key] = value

// Mark projection dirty for transforms
if (transformProps.has(key) && this.projection) {
this.projection.isTransformDirty = true
}

// Note: onUpdate is handled by state's render callbacks for most values,
// but we still schedule render for backward compatibility
this.scheduleRender()
},
// Provide access to full latestValues for transform building
// This includes values from initial/animate that may not be in state
getLatestValues: () => this.latestValues,
})
}

readValueFromInstance(
instance: HTMLElement,
key: string
Expand Down
13 changes: 13 additions & 0 deletions packages/framer-motion/src/render/object/ObjectVisualElement.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { MotionValueState } from "motion-dom"
import { createBox } from "../../projection/geometry/models"
import { ResolvedValues } from "../types"
import { VisualElement } from "../VisualElement"
Expand All @@ -16,6 +17,18 @@ export class ObjectVisualElement extends VisualElement<
> {
type = "object"

createMotionValueState(): MotionValueState {
return new MotionValueState({
// Don't apply value types (e.g., "px") to plain objects
useDefaultValueType: false,
onValueChange: (key, value) => {
// Sync to latestValues and schedule render
this.latestValues[key] = value
this.scheduleRender()
},
})
}

readValueFromInstance(instance: Object, key: string) {
if (isObjectKey(key, instance)) {
const value = instance[key]
Expand Down
Loading