From 4ef0ca3b3e38488c649c2e329e24ae3ae253c3ed Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Mon, 17 Aug 2026 19:53:13 +0200 Subject: [PATCH 1/3] Memoize projection style writes to skip redundant CSSOM setters Layout animations render every projecting node every frame, but the projection-written transform, transformOrigin and visibility often don't change frame-to-frame, and CSSOM setter calls (string parse + style invalidation) dominate per-frame projection cost at scale. applyProjectionStyles now caches the last written values and skips value-equal writes. While a node is projecting it owns the transform (willProjectTransform), so renderHTML skips the base transform write - removing a doubled write per projecting element per frame and guaranteeing the memoized values can't be invalidated externally. The caches are cleared whenever styles may have been written outside the projection pipeline (setOptions, resetTransform, needsReset, and when projection stops). Co-authored-by: Cursor --- .../projection/node/create-projection-node.ts | 85 +++++++++++++++++-- .../motion-dom/src/projection/node/types.ts | 11 +++ .../src/render/html/utils/render.ts | 17 ++++ 3 files changed, 105 insertions(+), 8 deletions(-) diff --git a/packages/motion-dom/src/projection/node/create-projection-node.ts b/packages/motion-dom/src/projection/node/create-projection-node.ts index fdb7227316..4e03eec662 100644 --- a/packages/motion-dom/src/projection/node/create-projection-node.ts +++ b/packages/motion-dom/src/projection/node/create-projection-node.ts @@ -991,6 +991,7 @@ export function createProjectionNode({ ) { resetTransform(this.instance, transformTemplateValue) this.shouldResetTransform = false + this.clearRenderCache() this.scheduleRender() } } @@ -1153,6 +1154,13 @@ export function createProjectionNode({ crossfade: options.crossfade !== undefined ? options.crossfade : true, } + + /** + * A React render may have written styles outside of the + * projection pipeline, so the memoized projection writes can + * no longer be trusted. + */ + this.clearRenderCache() } clearMeasurements() { @@ -1490,6 +1498,7 @@ export function createProjectionNode({ */ if (this.prevProjectionDelta) { this.createProjectionDeltas() + this.clearRenderCache() this.scheduleRender() } @@ -1984,6 +1993,42 @@ export function createProjectionNode({ visualElement.scheduleRender() } + /** + * Caches of the last projection-written transform styles. Layout + * animations render every projecting node every frame, but these + * writes often don't change frame-to-frame. Comparing against + * these caches lets us skip the CSSOM setter calls, which + * dominate per-frame projection cost at scale. + */ + renderedTransform: string | undefined + renderedOriginX: number = -1 + renderedOriginY: number = -1 + wroteHidden = false + + clearRenderCache() { + this.renderedTransform = undefined + this.renderedOriginX = this.renderedOriginY = -1 + } + + /** + * Whether applyProjectionStyles will write a transform this + * render. When true, style renders can skip writing user + * transforms as projection owns (and incorporates) them, + * avoiding a doubled CSSOM write per projecting element per + * frame and keeping the memoized projection writes valid. + */ + willProjectTransform() { + if (!this.instance || this.isSVG || !this.isVisible) { + return false + } + + if (this.needsReset) return true + + return Boolean( + this.projectionDelta && this.layout && this.getLead().target + ) + } + applyProjectionStyles( targetStyle: any, // CSSStyleDeclaration - doesn't allow numbers to be assigned to properties styleProp?: MotionStyle @@ -1991,16 +2036,24 @@ export function createProjectionNode({ if (!this.instance || this.isSVG) return if (!this.isVisible) { - targetStyle.visibility = "hidden" + if (!this.wroteHidden) { + this.wroteHidden = true + targetStyle.visibility = "hidden" + } return } + if (this.wroteHidden) { + this.wroteHidden = false + targetStyle.visibility = "" + } + const transformTemplate = this.getTransformTemplate() if (this.needsReset) { this.needsReset = false - targetStyle.visibility = "" + this.clearRenderCache() targetStyle.opacity = "" targetStyle.pointerEvents = resolveMotionValue(styleProp?.pointerEvents) || "" @@ -2025,13 +2078,12 @@ export function createProjectionNode({ ? transformTemplate({}, "") : "none" this.hasProjected = false + this.clearRenderCache() } return } - targetStyle.visibility = "" - const valuesToRender = lead.animationValues || lead.latestValues this.applyTransformsToTarget() @@ -2045,12 +2097,29 @@ export function createProjectionNode({ transform = transformTemplate(valuesToRender, transform) } - targetStyle.transform = transform + /** + * The following writes are memoized against the last + * projection-rendered value as CSSOM setter calls are the + * dominant cost of rendering large numbers of projection + * nodes every frame. Projection owns these styles while + * projecting (see willProjectTransform), so the caches can't + * be invalidated by the regular style render. + */ + if (transform !== this.renderedTransform) { + this.renderedTransform = targetStyle.transform = transform + } const { x, y } = this.projectionDelta - targetStyle.transformOrigin = `${x.origin * 100}% ${ - y.origin * 100 - }% 0` + if ( + x.origin !== this.renderedOriginX || + y.origin !== this.renderedOriginY + ) { + this.renderedOriginX = x.origin + this.renderedOriginY = y.origin + targetStyle.transformOrigin = `${x.origin * 100}% ${ + y.origin * 100 + }% 0` + } if (lead.animationValues) { /** diff --git a/packages/motion-dom/src/projection/node/types.ts b/packages/motion-dom/src/projection/node/types.ts index 6b2bab5121..cecac42405 100644 --- a/packages/motion-dom/src/projection/node/types.ts +++ b/packages/motion-dom/src/projection/node/types.ts @@ -112,6 +112,17 @@ export interface IProjectionNode { targetStyle: CSSStyleDeclaration, styleProp?: MotionStyle ): void + /** + * Whether applyProjectionStyles will write a transform this render. + * When true, style renders skip writing user transforms as projection + * owns (and incorporates) them. + */ + willProjectTransform(): boolean + /** + * Invalidate the memoized projection style writes, e.g. when styles + * may have been written outside the projection pipeline. + */ + clearRenderCache(): void clearMeasurements(): void resetTree(): void diff --git a/packages/motion-dom/src/render/html/utils/render.ts b/packages/motion-dom/src/render/html/utils/render.ts index dd20bda831..1054324313 100644 --- a/packages/motion-dom/src/render/html/utils/render.ts +++ b/packages/motion-dom/src/render/html/utils/render.ts @@ -9,8 +9,25 @@ export function renderHTML( ) { const elementStyle = element.style + /** + * When projection is going to write a transform it owns (and + * incorporates) the user transform, so skip writing it here. This + * avoids a doubled CSSOM write per projecting element per frame and + * keeps projection's memoized transform writes valid. + */ + const projectionWillWriteTransform = projection + ? projection.willProjectTransform() + : false + let key: string for (key in style) { + if ( + projectionWillWriteTransform && + (key === "transform" || key === "transformOrigin") + ) { + continue + } + // CSSStyleDeclaration has [index: number]: string; in the types, so we use that as key type. elementStyle[key as unknown as number] = style[key] as string } From f16875111032a4ff7b6944b6515ceda9fd3f3f31 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Mon, 17 Aug 2026 20:29:47 +0200 Subject: [PATCH 2/3] Fix memoization staleness around hide/show Review findings: hide()/show() didn't invalidate the memoized projection writes, so a node could skip writing its correct transform after being shown again; and the hidden-visibility write was memoized even though the preceding style render can re-write visibility each frame. Clear the caches on visibility changes and always re-hide while hidden. Co-authored-by: Cursor --- dev/react/src/App.tsx | 8 ++++++++ .../projection/node/create-projection-node.ts | 18 ++++++++++++++---- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/dev/react/src/App.tsx b/dev/react/src/App.tsx index 0974ee6be4..6f93e56239 100644 --- a/dev/react/src/App.tsx +++ b/dev/react/src/App.tsx @@ -1,5 +1,13 @@ +import { recordStats } from "framer-motion/debug" import { StrictMode } from "react" +/** + * Expose Motion's stats recorder for benchmarking/automation. + * Usage from devtools or a driver: + * window.__report = window.recordStats(); ...; window.__report() + */ +;(window as any).recordStats = recordStats + const examples = import.meta.glob("./examples/*.tsx", { eager: true, import: "App", diff --git a/packages/motion-dom/src/projection/node/create-projection-node.ts b/packages/motion-dom/src/projection/node/create-projection-node.ts index 4e03eec662..3b8ac6d7a4 100644 --- a/packages/motion-dom/src/projection/node/create-projection-node.ts +++ b/packages/motion-dom/src/projection/node/create-projection-node.ts @@ -1562,10 +1562,17 @@ export function createProjectionNode({ isVisible = true hide() { this.isVisible = false + /** + * While hidden, style renders write non-projected styles to + * the element, so the memoized projection writes can't be + * trusted once visibility changes. + */ + this.clearRenderCache() // TODO: Schedule render } show() { this.isVisible = true + this.clearRenderCache() // TODO: Schedule render } @@ -2036,10 +2043,13 @@ export function createProjectionNode({ if (!this.instance || this.isSVG) return if (!this.isVisible) { - if (!this.wroteHidden) { - this.wroteHidden = true - targetStyle.visibility = "hidden" - } + /** + * The preceding style render may have re-written + * visibility, so always re-hide. wroteHidden tracks that + * a restoring write is needed when the node is shown. + */ + targetStyle.visibility = "hidden" + this.wroteHidden = true return } From 3291a5773300ada0efe971191410c6a800a7816a Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Mon, 17 Aug 2026 20:30:12 +0200 Subject: [PATCH 3/3] Remove dev app change unrelated to this branch Co-authored-by: Cursor --- dev/react/src/App.tsx | 8 -------- 1 file changed, 8 deletions(-) diff --git a/dev/react/src/App.tsx b/dev/react/src/App.tsx index 6f93e56239..0974ee6be4 100644 --- a/dev/react/src/App.tsx +++ b/dev/react/src/App.tsx @@ -1,13 +1,5 @@ -import { recordStats } from "framer-motion/debug" import { StrictMode } from "react" -/** - * Expose Motion's stats recorder for benchmarking/automation. - * Usage from devtools or a driver: - * window.__report = window.recordStats(); ...; window.__report() - */ -;(window as any).recordStats = recordStats - const examples = import.meta.glob("./examples/*.tsx", { eager: true, import: "App",