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
93 changes: 86 additions & 7 deletions packages/motion-dom/src/projection/node/create-projection-node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -991,6 +991,7 @@ export function createProjectionNode<I>({
) {
resetTransform(this.instance, transformTemplateValue)
this.shouldResetTransform = false
this.clearRenderCache()
this.scheduleRender()
}
}
Expand Down Expand Up @@ -1153,6 +1154,13 @@ export function createProjectionNode<I>({
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() {
Expand Down Expand Up @@ -1490,6 +1498,7 @@ export function createProjectionNode<I>({
*/
if (this.prevProjectionDelta) {
this.createProjectionDeltas()
this.clearRenderCache()
this.scheduleRender()
}

Expand Down Expand Up @@ -1553,10 +1562,17 @@ export function createProjectionNode<I>({
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
}

Expand Down Expand Up @@ -1984,23 +2000,70 @@ export function createProjectionNode<I>({
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
)
}
Comment on lines +2010 to +2037

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Projection caching lacks regression tests

This adds stateful style caches, several lifecycle invalidation paths, and conditional suppression of normal DOM writes without adding automated coverage, leaving stale-style and ownership regressions undetected despite the repository requirement to test every new feature.

Context Used: CLAUDE.md (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!


applyProjectionStyles(
targetStyle: any, // CSSStyleDeclaration - doesn't allow numbers to be assigned to properties
styleProp?: MotionStyle
) {
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) || ""
Expand All @@ -2025,13 +2088,12 @@ export function createProjectionNode<I>({
? transformTemplate({}, "")
: "none"
this.hasProjected = false
this.clearRenderCache()
}

return
}

targetStyle.visibility = ""

const valuesToRender = lead.animationValues || lead.latestValues
this.applyTransformsToTarget()

Expand All @@ -2045,12 +2107,29 @@ export function createProjectionNode<I>({
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
}
Comment on lines +2118 to +2120

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 External writes bypass transform cache

When styleEffect writes transform or transformOrigin on an actively projecting element and the next projection value equals the cached value, this guard skips the CSSOM setter even though the DOM no longer contains that value, leaving the external transform applied and rendering the element at the wrong position.

Knowledge Base Used:


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) {
/**
Expand Down
11 changes: 11 additions & 0 deletions packages/motion-dom/src/projection/node/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,17 @@ export interface IProjectionNode<I = unknown> {
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

Expand Down
17 changes: 17 additions & 0 deletions packages/motion-dom/src/render/html/utils/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down