Memoize projection style writes to skip redundant CSSOM setters - #3793
Memoize projection style writes to skip redundant CSSOM setters#3793mattgperry wants to merge 3 commits into
Conversation
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 <cursoragent@cursor.com>
Greptile SummaryThe PR memoizes projection-owned transform, transform-origin, and visibility writes, and prevents the normal HTML renderer from duplicating transform writes while projection is active.
Confidence Score: 3/5The PR should not merge until projection caches remain correct after visibility MotionValue renders and imperative transform-style writes. The new caches can diverge from the actual CSSOM when another supported rendering path overwrites visibility, transform, or transformOrigin, causing projection to skip the write needed to restore the correct visual state. Files Needing Attention: packages/motion-dom/src/projection/node/create-projection-node.ts, packages/motion-dom/src/render/html/utils/render.ts Important Files Changed
Sequence DiagramsequenceDiagram
participant R as React/style render
participant H as renderHTML
participant P as ProjectionNode
participant D as element.style
R->>P: setOptions / schedule render
P->>P: clearRenderCache()
H->>P: willProjectTransform()
alt Projection owns transform
H-->>D: Skip transform and transformOrigin
P->>P: Build projection transform
alt Value differs from cache
P->>D: Write transform/origin
P->>P: Update cache
else Value equals cache
P-->>D: Skip CSSOM setter
end
else Base renderer owns transform
H->>D: Write normal styles
end
Reviews (1): Last reviewed commit: "Memoize projection style writes to skip ..." | Re-trigger Greptile |
| ) { | ||
| if (!this.instance || this.isSVG) return | ||
|
|
||
| if (!this.isVisible) { | ||
| targetStyle.visibility = "hidden" | ||
| if (!this.wroteHidden) { | ||
| this.wroteHidden = true |
There was a problem hiding this comment.
Hidden visibility cache stays stale
When a crossfade-disabled shared-layout node is hidden while a MotionValue-backed visibility style continues rendering, renderHTML overwrites visibility: hidden, but wroteHidden remains true, so applyProjectionStyles skips restoring it and the previous lead becomes visible over the promoted element.
Knowledge Base Used:
| if (transform !== this.renderedTransform) { | ||
| this.renderedTransform = targetStyle.transform = transform | ||
| } |
There was a problem hiding this comment.
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:
| 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 | ||
| ) | ||
| } |
There was a problem hiding this comment.
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!
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 <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Summary
applyProjectionStylesnow caches the last projection-writtentransform,transformOriginand hidden-visibility state, skipping value-equal writes.willProjectTransform()), sorenderHTMLskips the base transform/transformOrigin write — removing a doubled CSSOM write per projecting element per frame and guaranteeing the memoized values can't be invalidated by the regular style render.setOptions(React re-render),resetTransform,needsReset, and when projection stops (hasProjectedreset, target lost).Measured on the
layout-stress-transformdev example (1,513 projection nodes):applyProjectionStylesself-time −20% and the per-framerenderHTMLtransform write eliminated; in interleaved A/B profiling combined with follow-up projection work, total projection-attributed JS reduced ~15%.Test plan
motion-dom+framer-motionunit suites (508 + 814 passing)layout*.ts, 68 tests) against React 18layout-stress-transformbefore/after via V8 sampling profiler