Skip to content

Memoize projection style writes to skip redundant CSSOM setters - #3793

Closed
mattgperry wants to merge 3 commits into
mainfrom
projection-write-memoization
Closed

Memoize projection style writes to skip redundant CSSOM setters#3793
mattgperry wants to merge 3 commits into
mainfrom
projection-write-memoization

Conversation

@mattgperry

Copy link
Copy Markdown
Collaborator

Summary

  • Profiling large layout animations (1,000+ projecting nodes) showed CSSOM setter calls — browser-side string parse + style invalidation, attributed to JS — are the single largest component of per-frame projection cost. Many of these writes don't change frame-to-frame (transformOrigin, visibility, and transform in animation tails/steady segments).
  • applyProjectionStyles now caches the last projection-written transform, transformOrigin and hidden-visibility state, skipping value-equal writes.
  • While a node is projecting it owns the transform (willProjectTransform()), so renderHTML skips 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.
  • Caches are invalidated whenever styles may be written outside the projection pipeline: setOptions (React re-render), resetTransform, needsReset, and when projection stops (hasProjected reset, target lost).

Measured on the layout-stress-transform dev example (1,513 projection nodes): applyProjectionStyles self-time −20% and the per-frame renderHTML transform write eliminated; in interleaved A/B profiling combined with follow-up projection work, total projection-attributed JS reduced ~15%.

Test plan

  • motion-dom + framer-motion unit suites (508 + 814 passing)
  • All layout Cypress specs (layout*.ts, 68 tests) against React 18
  • Profiled layout-stress-transform before/after via V8 sampling profiler

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-apps

greptile-apps Bot commented Aug 17, 2026

Copy link
Copy Markdown

Greptile Summary

The PR memoizes projection-owned transform, transform-origin, and visibility writes, and prevents the normal HTML renderer from duplicating transform writes while projection is active.

  • Adds per-node projection style caches and lifecycle invalidation.
  • Adds a predicate for deciding when projection owns transform rendering.
  • Skips base transform and transform-origin writes while projection will supply them.

Confidence Score: 3/5

The 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

Filename Overview
packages/motion-dom/src/projection/node/create-projection-node.ts Adds projection style memoization and invalidation, but visibility and imperative external style writes can leave the local caches inconsistent with the CSSOM.
packages/motion-dom/src/render/html/utils/render.ts Coordinates transform ownership with projection by suppressing base transform writes while projection is active.
packages/motion-dom/src/projection/node/types.ts Extends the ProjectionNode interface with transform-ownership and cache-invalidation methods consistent with the implementation.

Sequence Diagram

sequenceDiagram
    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
Loading

Reviews (1): Last reviewed commit: "Memoize projection style writes to skip ..." | Re-trigger Greptile

Comment on lines +2035 to +2040
) {
if (!this.instance || this.isSVG) return

if (!this.isVisible) {
targetStyle.visibility = "hidden"
if (!this.wroteHidden) {
this.wroteHidden = true

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 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:

Comment on lines +2108 to +2110
if (transform !== this.renderedTransform) {
this.renderedTransform = targetStyle.transform = transform
}

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:

Comment on lines +2003 to +2030
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
)
}

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!

mattgperry and others added 2 commits August 17, 2026 20:29
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>
@mattgperry mattgperry closed this Aug 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant