Skip to content

[audit] motion-dom/projection: reduce per-frame allocations#3645

Merged
mattgperry merged 2 commits intomainfrom
audit/motion-dom-projection
Mar 16, 2026
Merged

[audit] motion-dom/projection: reduce per-frame allocations#3645
mattgperry merged 2 commits intomainfrom
audit/motion-dom-projection

Conversation

@mattgperry
Copy link
Copy Markdown
Collaborator

What this directory does

motion-dom/src/projection/ implements the layout animation projection system. It handles per-frame box math during layout animations — measuring element positions, calculating deltas between layout states, applying tree transforms, and building CSS projection transforms. This is the hottest path in layout animations: every projecting node runs resolveTargetDelta, calcProjection, and applyProjectionStyles every frame.

Findings

Audited all 25 source files for per-frame allocation and unnecessary computation:

  1. Per-frame box allocation in resolveTargetDelta — When an element resumes from another (shared layout transitions), this.applyTransform() was called every frame, creating a new Box object via createBox() each time. A TODO comment acknowledged this.

  2. Per-frame object allocations for scroll offsets — In applyTreeDeltas and applyTransform, scroll offset corrections created a new { x, y } object each call, then passed it through transformBoxtransformAxisapplyAxisDeltascalePoint — all to perform a simple translation. translateAxis does the same work with zero allocations.

  3. Per-frame string concatenation in mixValues — The border radius mixing loop built `border${borders[i]}Radius` template strings on every frame (4 strings × every frame during crossfade). Pre-computing the labels avoids this.

  4. Redundant createBox() in updateLayoutlayoutCorrected was replaced with a fresh box on every layout update, but calcProjection immediately overwrites it via copyBoxInto. Lazy-initializing once is sufficient.

Changes

File Change Rationale
node/create-projection-node.ts Inline applyTransform logic at resolveTargetDelta to write directly into this.target Eliminates per-frame createBox() allocation (resolves TODO)
node/create-projection-node.ts Replace transformBox(box, {x, y}) with translateAxis calls in applyTransform Avoids per-call object allocation + unnecessary function chain for simple translation
geometry/delta-apply.ts Replace transformBox(box, {x, y}) with translateAxis in applyTreeDeltas Same — avoids allocation in per-frame hot loop
geometry/delta-apply.ts Use += in translateAxis Minor: cleaner, same semantics
animation/mix-values.ts Pre-compute borderLabels array with full strings Avoids 4 template literal concatenations per frame during crossfade
node/create-projection-node.ts Lazy-init layoutCorrected instead of createBox() each updateLayout Avoids redundant allocation that gets immediately overwritten

Test plan

  • yarn build passes
  • yarn test passes (all 92 suites, 774 tests)

🤖 Generated with Claude Code

- Pre-compute border radius label strings to avoid template literal concat per frame
- Replace scroll offset object allocations with direct translateAxis calls in applyTreeDeltas and applyTransform
- Inline applyTransform at resolveTargetDelta to write directly into this.target, eliminating per-frame createBox() allocation (resolves TODO)
- Reuse layoutCorrected box instead of creating a new one each updateLayout

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented Mar 14, 2026

Greptile Summary

This PR reduces per-frame memory allocations in the layout animation projection system — the hottest path during layout animations. Every projecting node runs resolveTargetDelta, calcProjection, and applyProjectionStyles each frame, making allocation reduction here directly impactful on GC pressure.

  • Eliminated per-frame createBox() in resolveTargetDelta: Inlined applyTransform logic to write directly into the existing this.target box instead of creating and discarding a new box object every frame during shared layout transitions (resolves an existing TODO).
  • Replaced transformBox(box, {x, y}) with translateAxis calls: In both applyTreeDeltas and applyTransform, scroll offset corrections previously created a throwaway {x, y} object and routed it through transformBox → transformAxis → applyAxisDelta → scalePoint — all to perform a simple translation. Direct translateAxis calls achieve the same result with zero allocations and fewer function calls.
  • Pre-computed border radius labels: The mixValues crossfade loop was building 4 template literal strings (`border${suffix}Radius`) every frame. These are now pre-computed in a static array.
  • Lazy-initialized layoutCorrected: Previously recreated via createBox() on every updateLayout call, but calcProjection immediately overwrites it via copyBoxInto. Now initialized once on first use.

Confidence Score: 5/5

  • This PR is safe to merge — all changes are semantically equivalent optimizations with no behavioral differences.
  • Every optimization was verified against the original implementation: the translateAxis replacement is mathematically equivalent to the transformBox path with scale=1, the inlined applyTransform exactly matches the method's behavior when transformOnly=false, the lazy init of layoutCorrected is safe because calcProjection always overwrites it, and the pre-computed border labels are trivially correct. Tests pass (774 tests across 92 suites). No new code paths or behavioral changes are introduced.
  • No files require special attention

Important Files Changed

Filename Overview
packages/motion-dom/src/projection/animation/mix-values.ts Pre-computes border radius label strings into a static array, eliminating 4 template literal allocations per frame during crossfade animations. Straightforward and correct.
packages/motion-dom/src/projection/geometry/delta-apply.ts Replaces transformBox(box, {x, y}) with direct translateAxis calls in applyTreeDeltas, avoiding per-frame object allocation. Also uses += in translateAxis for cleaner syntax. Both changes are semantically equivalent to the originals.
packages/motion-dom/src/projection/node/create-projection-node.ts Three optimizations: (1) inlines applyTransform logic into resolveTargetDelta to write directly into this.target instead of allocating a new box each frame, (2) replaces transformBox with translateAxis for scroll offsets in applyTransform, (3) lazy-inits layoutCorrected since calcProjection immediately overwrites it. All changes verified correct against the original implementations.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["resolveTargetDelta (per frame)"] --> B{resumingFrom?}
    B -->|Yes - OLD| C["applyTransform(layoutBox)"]
    C --> D["createBox() ❌ allocation"]
    D --> E["copyBoxInto(newBox, layoutBox)"]
    E --> F["loop: transformBox({x,y}) ❌ allocation"]
    F --> G["return newBox → this.target = newBox"]
    
    B -->|Yes - NEW| H["copyBoxInto(this.target, layoutBox)"]
    H --> I["loop: translateAxis ✅ zero alloc"]
    I --> J["transformBox on this.target in-place"]
    
    B -->|No| K["copyBoxInto(this.target, layoutBox)"]
    
    L["applyTreeDeltas (per frame)"] --> M{scroll offset?}
    M -->|OLD| N["transformBox(box, {x,y}) ❌ allocation"]
    M -->|NEW| O["translateAxis(box.x/y) ✅ zero alloc"]
    
    P["mixValues (per frame)"] --> Q{border radius?}
    Q -->|OLD| R["template literal ❌ 4 strings/frame"]
    Q -->|NEW| S["static array lookup ✅ zero alloc"]
Loading

Last reviewed commit: bbdb6ab

Instead of inlining the applyTransform logic, add an output
parameter so callers can pass an existing box to write into,
avoiding per-frame allocation without code duplication.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@mattgperry mattgperry merged commit 032b5cf into main Mar 16, 2026
5 checks passed
@mattgperry mattgperry deleted the audit/motion-dom-projection branch March 16, 2026 12:59
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