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..3b8ac6d7a4 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() } @@ -1553,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 } @@ -1984,6 +2000,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 +2043,27 @@ export function createProjectionNode({ if (!this.instance || this.isSVG) return if (!this.isVisible) { + /** + * 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 } + 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 +2088,12 @@ export function createProjectionNode({ ? transformTemplate({}, "") : "none" this.hasProjected = false + this.clearRenderCache() } return } - targetStyle.visibility = "" - const valuesToRender = lead.animationValues || lead.latestValues this.applyTransformsToTarget() @@ -2045,12 +2107,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 }