Skip to content

Conversation

@mattgperry
Copy link
Collaborator

@mattgperry mattgperry commented Jan 13, 2026

No description provided.

Introduces a new `animateLayout` function that enables FLIP-style layout animations
without React. Key features:

- Automatic before/after snapshot detection
- Enter/exit animations with `.enter()` and `.exit()` builder methods
- Shared element transitions via `data-layout-id` attribute
- Crossfade support for elements with matching layout IDs
- Scale correction for border-radius and box-shadow

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@mattgperry mattgperry changed the title Add animateLayout API for vanilla JS layout animations Refactor Jan 13, 2026
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@motiondivision motiondivision deleted a comment from claude bot Jan 13, 2026
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@claude
Copy link

claude bot commented Jan 13, 2026

PR Review: Layout Animation System Refactor

Overview

This PR introduces a new layout animation system to motion-dom through the unstable_animateLayout API. The implementation adds ~1700 lines of code across 4 new modules plus comprehensive E2E test coverage.

Strengths

Architecture & Design

  • Clean builder pattern with intuitive, chainable API
  • Well-structured 5-phase lifecycle (snapshot → mutation → detect → animate → cleanup)
  • Good separation of concerns across distinct modules
  • Comprehensive E2E test coverage (9 tests covering various scenarios)

Code Quality

  • Strong TypeScript typing throughout
  • Error handling with try-catch and cleanup
  • Proper memory management via cleanupProjectionTree
  • Good JSDoc documentation with examples

Issues & Concerns

1. Potential Race Condition in Builder Execution

Location: LayoutAnimationBuilder.ts:50

The builder executes on a microtask via queueMicrotask(). Builder methods like .enter(), .exit(), and .shared() must be called synchronously, but this isn't enforced or documented.

Risk: If a user adds await before chaining methods, execution will have already started and config will be ignored.

Recommendation: Add guard in builder methods to throw if this.executed === true, and document this requirement.

2. Missing Null Safety in Parent Check (BLOCKING)

Location: LayoutAnimationBuilder.ts:220

parentElement is typed as HTMLElement but could be null.

Fix: Use optional chaining: if (\!parentElement?.isConnected) continue

3. Non-Null Assertion Without Guard (BLOCKING)

Location: projection-tree.ts:186

root! could be undefined if buildProjectionTree is called with empty array and no existingContext.

Fix: Add guard or throw meaningful error before returning.

4. Type Confusion

Location: LayoutAnimationBuilder.ts:342

The ternary scope instanceof Document ? scope : scope is redundant.

5. Opacity Manipulation Bypasses Motion Values

Location: LayoutAnimationBuilder.ts:274

Directly setting style.opacity bypasses Motion internal state management.

Recommendation: Use visualElement.setStaticValue("opacity", 0)

6. Potential Memory Leak with Exit Elements (BLOCKING)

Location: LayoutAnimationBuilder.ts:129

If groupAnimation.finished never resolves, exit elements remain in DOM forever.

Fix: Handle both resolution and rejection: groupAnimation.finished.then(cleanup, cleanup)

7. Missing Return Type Annotation

Location: can-animate.ts:7-13

Per CLAUDE.md, functions should have explicit return types. hasKeyframesChanged returns boolean | undefined.

8. Scale Corrector Global State

Location: projection-tree.ts:14-36

Module-level singleton could cause issues in test isolation, SSR, and multi-instance scenarios.

Performance Considerations

Positive:

  • Lazy DOM queries (data-layout attributes only)
  • Sorted tree building (parents before children)
  • Batch updates via frame scheduling
  • Context reuse

Concerns:

  1. querySelectorAll on every mutation could be slow for large DOMs
  2. No element limit safeguard
  3. getDepth walks entire parent chain for every element

Security

✅ No security issues identified

Test Coverage

Excellent E2E coverage but missing unit tests for core logic (mutation detection, tree building, etc.).

Per CLAUDE.md: "Always write a failing test FIRST before implementing any bug fix or feature."

Recommendation: Add Jest unit tests for detectMutations, buildProjectionTree, etc.

Minor Suggestions

  1. Consider removing unstable_ prefix timeline
  2. Use consistent terminology (mutation vs update)
  3. Add @throws tags to JSDoc
  4. Consider stats integration
  5. Add devtools hooks

Verdict

Overall: Well-architected, production-quality implementation with excellent E2E coverage.

Blocking Issues: #2, #3, #6 should be fixed before merge

Recommendation: ✅ Approve with requested changes

Great work on this complex feature! The projection system integration is particularly well done. 🚀

@claude
Copy link

claude bot commented Jan 13, 2026

Pull Request Review: Layout Animation Refactor

Summary

This PR introduces a comprehensive layout animation system (unstable_animateLayout) to motion-dom, providing FLIP-style layout animations with enter/exit animations and shared element transitions. The implementation includes ~1700 lines of new code with 9 HTML test files.


✅ Strengths

1. Excellent Architecture & Design

  • Clean separation of concerns: LayoutAnimationBuilder orchestrates, detect-mutations tracks DOM changes, projection-tree manages animation nodes
  • Builder pattern: Fluent API (.enter(), .exit(), .shared()) is intuitive and chainable
  • Phase-based execution: Clear 5-phase lifecycle (snapshot → mutate → detect → animate → cleanup)
  • Async-first design: Uses queueMicrotask() to defer execution, allowing builder configuration before DOM updates

2. Robust Implementation

  • Proper cleanup: Exit animations reattach elements as position: absolute to prevent layout shifts, then remove after completion
  • Shared element handling: Correctly identifies and coordinates transitions when elements with matching data-layout-id are swapped
  • Scale correction: Applies border-radius and box-shadow correctors to prevent distortion during FLIP animations
  • Error handling: Try-catch with cleanup in error paths

3. Comprehensive Test Coverage

  • 9 E2E test scenarios covering:
    • Basic position changes with paused animation verification
    • Enter/exit animations with opacity checks
    • Scoped animations (ensuring elements outside scope aren't affected)
    • Scale correction (child maintains aspect ratio during parent resize)
    • Shared element transitions (basic, crossfade, custom configs, multiple elements)
  • Visual regression approach: Tests use data-layout-correct attributes with red highlighting for failures
  • Practical assertions: Checking element connectivity, bounds, opacity, aspect ratios

4. Code Quality

  • Follows CLAUDE.md conventions: Named exports, interfaces over types, proper use of arrow callbacks
  • Good naming: Clear, descriptive variable/function names
  • Documentation: JSDoc comments explaining API usage and element requirements

🔍 Issues & Concerns

Critical Issues

1. Missing Return Statement in canAnimate (packages/motion-dom/src/animation/utils/can-animate.ts:13)

function hasKeyframesChanged(keyframes: ResolvedKeyframes<any>) {
    const current = keyframes[0]
    if (keyframes.length === 1) return true
    for (let i = 0; i < keyframes.length; i++) {
        if (keyframes[i] !== current) return true
    }
    // ⚠️ No return statement here! Returns undefined when all keyframes are identical
}

Impact: When all keyframes are the same (e.g., [0, 0, 0]), function returns undefined instead of false, causing unpredictable animation behavior.

Fix:

function hasKeyframesChanged(keyframes: ResolvedKeyframes<any>) {
    const current = keyframes[0]
    if (keyframes.length === 1) return true
    for (let i = 0; i < keyframes.length; i++) {
        if (keyframes[i] !== current) return true
    }
    return false  // Add this
}

2. Redundant Type Coercion (packages/motion-dom/src/layout/LayoutAnimationBuilder.ts:342)

return {
    scope: scope instanceof Document ? scope : scope,  // ⚠️ Ternary does nothing
    updateDom: updateDomOrOptions as () => void,
    defaultOptions: options,
}

Fix: scope: scope or verify if this should check for Element vs Document

High Priority Issues

3. Memory Leak Risk: Missing parentElement Validation

In reattachExitingElements (LayoutAnimationBuilder.ts:220), if a parent is disconnected, the code skips reattachment but doesn't prevent the element from being added to the cleanup list. If cleanup runs later, it may fail silently or create orphaned DOM references.

Recommendation:

private reattachExitingElements(exiting: RemovedElement[]) {
    const successfulReattachments: RemovedElement[] = []
    for (const item of exiting) {
        if (!item.parentElement.isConnected) continue
        // ... reattach logic ...
        successfulReattachments.push(item)
    }
    return successfulReattachments  // Only cleanup successfully reattached elements
}

4. Race Condition: Projection Node Animations

At line 168 in LayoutAnimationBuilder.ts, animations are collected synchronously after frame.postRender() callback, but there's no guarantee all nodes have created their animations. The projection system schedules animations via frame.update, which might not have executed yet.

Consider: Add validation or a second sync point to ensure animations exist before collection.

5. Unsafe Type Assertion

return {
    // ...
    root: root!,  // ⚠️ Non-null assertion without guarantee
}

In buildProjectionTree (projection-tree.ts:186), if elements is empty and existingContext?.root is undefined, this creates a null reference error.

Fix: Add explicit validation:

if (!root) {
    throw new Error("Failed to build projection tree: no root node created")
}

Medium Priority Issues

6. Inconsistent Opacity Handling

In animateEntering (LayoutAnimationBuilder.ts:268-276), the code sets element.style.opacity = "0" only when target opacity is 1. This doesn't handle:

  • Arrays with non-1 values: [0, 0.5, 0.8]
  • Objects: { from: 0, to: 0.5 }
  • Missing opacity in keyframes but present in default styles

Recommendation: Use a more robust check or document the limitation.

7. No Abort Mechanism

Once execute() starts, there's no way to abort it mid-flight. If a component unmounts during the snapshot-mutate-detect phase, resources may be wasted.

Suggestion: Add an abort() method and check an aborted flag at key points.

8. Hardcoded Animation Defaults (projection-tree.ts:104, 212)

const nodeTransition = transition
    ? { duration: transition.duration, ease: transition.ease as any }
    : { duration: 0.3, ease: "easeOut" }  // ⚠️ Hardcoded fallback

These defaults might conflict with global motion config or user expectations.

Recommendation: Import from a shared constants file or respect MotionGlobalConfig.

Low Priority / Style Issues

9. Magic Numbers

  • Line 30 (MAX_RESOLVE_DELAY = 40): Document why 40ms
  • Line 165 (frame.postRender(() => resolve())): Explain timing requirement
  • Test files use duration: 10 everywhere - consider a named constant

10. Commented-Out Code

In exit-animation.html:71, there's a commented section:

// // Card should be in absolute position at original location

Should be cleaned up.

11. Unstable API Naming

The function is exported as unstable_animateLayout (line 274 in index.ts), but the PR has no description or roadmap for stabilization. Consider documenting:

  • What needs to happen before removing unstable_ prefix
  • Known limitations or missing features
  • Breaking change policy

🔒 Security Considerations

No major security issues identified. The code:

  • Doesn't use innerHTML unsafely (mutation callback is user-controlled)
  • Doesn't execute arbitrary scripts
  • Doesn't expose XSS vectors
  • Properly cleans up DOM references

Minor note: parseAnimateLayoutArgs uses resolveElements() which internally calls querySelectorAll. Ensure the library using this validates user-provided selectors if they come from untrusted sources.


⚡ Performance Considerations

Positives:

  • Efficient DOM queries: Uses querySelectorAll once per phase
  • Sorted tree building: Elements processed parent-first to avoid redundant parent lookups
  • Lazy animation creation: Deferred via microtask
  • Reuses contexts: Allows passing existing ProjectionContext to avoid rebuilding

Potential Optimizations:

  1. Reduce getBoundingClientRect calls: Currently called for every element in snapshotElements. Consider:

    • Batch reads during a single layout phase
    • Skip if element doesn't have layout changes pending
  2. WeakMap for element lookups: Map<HTMLElement, T> prevents garbage collection. Consider WeakMap where appropriate (though may not apply if you need iteration).

  3. Animation collection: Line 168-173 iterates all nodes to find those with currentAnimation. Could track animated nodes separately during creation.

  4. Shared element detection: Currently O(n) for each element to check parent hierarchy. Could build a Set of all entering/exiting element IDs upfront.


🧪 Test Coverage Assessment

Coverage: Good

The 9 HTML tests cover:

  • ✅ Layout position changes
  • ✅ Enter animations (scoped and unscoped)
  • ✅ Exit animations with reattachment
  • ✅ Scale correction for nested elements
  • ✅ Shared element transitions (basic, crossfade, custom config, multiple)

Missing coverage:

  • ❌ Error scenarios (e.g., invalid selectors, non-existent scope)
  • ❌ Rapid successive mutations (queueing/cancellation behavior)
  • ❌ Deeply nested hierarchies (performance/correctness)
  • ❌ Edge case: elements with display: none or visibility: hidden
  • ❌ Memory leak validation (cleanup verification)
  • ❌ Different data-layout modes (position, size, preserve-aspect)

Recommendation: Add unit tests for the detection/tree-building logic to complement E2E tests.


📋 Recommendations Summary

Must Fix Before Merge:

  1. ✅ Add missing return false in hasKeyframesChanged (can-animate.ts:13)
  2. ✅ Fix redundant ternary in parseAnimateLayoutArgs (LayoutAnimationBuilder.ts:342)
  3. ✅ Add null check before root! assertion (projection-tree.ts:186)

Strongly Recommended:

  1. Validate parent connectivity in cleanup to prevent memory leaks
  2. Add abort mechanism for component unmount scenarios
  3. Document animation timing/defaults or extract to constants
  4. Add unit tests for mutation detection logic

Nice to Have:

  1. Performance profiling on large DOMs (100+ animated elements)
  2. Document stabilization roadmap for unstable_ API
  3. Clean up commented code in test files

Final Verdict

Recommendation: Approve with Minor Changes 🟢

This is a well-architected, thoughtfully designed feature with excellent test coverage. The critical bug in hasKeyframesChanged should be fixed before merge, but otherwise the code is production-ready. The layout animation system fills a significant gap in the library and will enable powerful UI transitions.

Great work! The builder pattern API is clean, the phase-based execution is easy to reason about, and the test suite gives confidence in correctness. 🎉


Reviewed with ❤️ by Claude Code

Refactored animateLayout to leverage the projection system's existing
snapshot mechanism rather than using raw getBoundingClientRect. This
eliminates duplicate measurements and ensures proper transform-aware
measurements via willUpdate().

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@claude
Copy link

claude bot commented Jan 13, 2026

Pull Request Review: Refactor - Layout Animation System

I've completed a comprehensive review of this refactored layout animation system. Overall, this is excellent work with a well-architected solution. Here's my detailed feedback:


✅ Strengths

1. Architecture & Design

  • Clean separation of concerns: The builder pattern in LayoutAnimationBuilder provides an excellent fluent API
  • Projection system integration: Proper use of the projection tree for handling transforms and measurements
  • Phase-based execution: Clear 5-phase lifecycle (pre-mutation, mutation, detect, animate, cleanup) is well-structured
  • Shared element transitions: Smart handling via resumeFrom relationship instead of manual animation

2. Code Quality

  • Type safety: Excellent use of TypeScript interfaces and types throughout
  • Error handling: Proper try-catch with cleanup in error paths (LayoutAnimationBuilder.ts:207-213)
  • Memory management: Comprehensive cleanup in cleanupProjectionTree and exit element cleanup
  • Immutability: Good use of Map and Set for tracking state

3. Test Coverage

  • Comprehensive test suite: 9 HTML test files covering various scenarios:
    • Basic position changes
    • Enter/exit animations with scoping
    • Scale correction (preserve aspect)
    • Shared element transitions (basic, configured, crossfade, multiple)
  • Visual regression testing: Smart use of data-layout-correct attribute with Cypress assertions
  • Edge cases covered: Nested elements, scoped animations, crossfade timing

🔍 Potential Issues & Recommendations

1. Critical: Non-null Assertion Operator (projection-tree.ts:186)

root: root\!,  // Line 186

Issue: This assumes root will always be defined, but if all elements already have nodes, the loop never sets root.

Recommendation: Add validation:

if (\!root) {
    throw new Error('Failed to create projection tree: no root node found')
}
return { nodes, visualElements, group, root }

2. Performance: Microtask Queue Timing (LayoutAnimationBuilder.ts:51)

queueMicrotask(() => this.execute())

Issue: Using queueMicrotask for builder pattern execution is clever but could cause timing issues if DOM updates happen synchronously before builder methods are called.

Recommendation: Consider documenting this behavior clearly, or add a flag to track if builder methods were called before execution.

3. Type Safety: Force Non-null in shared() (LayoutAnimationBuilder.ts:71)

this.sharedTransitions.set(layoutIdOrOptions, options\!)

Issue: The options\! assertion assumes options is defined when layoutIdOrOptions is a string, but TypeScript doesn't enforce this at compile time.

Recommendation: Add runtime validation:

if (typeof layoutIdOrOptions === 'string') {
    if (\!options) {
        throw new Error('Options required when specifying layoutId')
    }
    this.sharedTransitions.set(layoutIdOrOptions, options)
}

4. Magic Number: Viewport Box Threshold (script-assert.js:19)

matchViewportBox: (element, expected, threshold = 0.01) => {

Issue: Default threshold of 0.01px is extremely strict and may cause flaky tests due to sub-pixel rendering differences across browsers.

Recommendation: Increase to at least 0.5-1px or make it configurable per-test.

5. Potential Memory Leak: Animation Cleanup (LayoutAnimationBuilder.ts:174-178)

for (const [element, node] of context.nodes.entries()) {
    if (sharedExitingElements.has(element)) continue
    if (node.currentAnimation) {
        animations.push(node.currentAnimation)
    }
}

Issue: If an animation is cancelled/interrupted before finished resolves, the cleanup handlers in line 198-204 may never run, leaving projection nodes mounted.

Recommendation: Add cleanup to all animation control methods (pause, cancel, stop) or use finally instead of then.

6. Redundant Code: parseAnimateLayoutArgs (LayoutAnimationBuilder.ts:356)

const scope = elements[0] || document
return {
    scope: scope instanceof Document ? scope : scope,  // Redundant ternary
    ...
}

Issue: The ternary operator returns scope in both branches.

Recommendation: Simplify to:

return {
    scope: elements[0] || document,
    ...
}

7. Edge Case: Opacity Initialization (LayoutAnimationBuilder.ts:287-289)

if (targetOpacity === 1) {
    ;(element as HTMLElement).style.opacity = '0'
}

Issue: This mutates the DOM element's style directly but doesn't restore it if the animation is cancelled. Also, the leading semicolon is unconventional.

Recommendation:

  • Store original opacity value for restoration
  • Use a more conventional code style

8. Missing Validation: Element Connection Check (detect-mutations.ts:226)

In reattachExitingElements, we check if (\!parentElement.isConnected) but don't validate that the element itself can be safely reattached.

Recommendation: Add validation that element isn't already connected elsewhere before reattaching.


🔒 Security Considerations

No security concerns identified. The code:

  • Doesn't use innerHTML internally (only in tests)
  • Properly scopes DOM queries
  • Uses safe DOM manipulation APIs
  • No user input processing without sanitization

⚡ Performance Considerations

Positive:

  • Efficient DOM queries: Single querySelectorAll per phase
  • Sorted tree building: Depth-first processing prevents re-processing
  • Lazy animation creation: Waits for frame.postRender before collecting animations
  • Scale correctors: Only added once via ensureScaleCorrectors()

Potential Improvements:

  1. Batch DOM reads/writes: Consider using frame.read/frame.update for layout thrashing prevention
  2. WeakMap for caching: Could use WeakMap for element-to-node lookups if performance becomes an issue
  3. Tree traversal optimization: isRootEnteringElement has O(n*depth) complexity - could be optimized with a Set of all ancestors

📝 Code Style & Best Practices

Excellent adherence to CLAUDE.md conventions:

  • Named exports (no default exports) ✓
  • Interface for types ✓
  • Arrow callbacks ✓
  • No var declarations ✓
  • Uses time.now() instead of performance.now()

Minor suggestions:

  • Add JSDoc comments for complex functions like reattachExitingElements
  • Consider extracting magic numbers (e.g., MAX_RESOLVE_DELAY = 40) to named constants

🧪 Test Coverage Assessment

Coverage: Excellent (9 comprehensive test files)

Well covered:

  • Layout animations (position, size)
  • Enter/exit animations
  • Shared element transitions
  • Scale correction
  • Scoping
  • Crossfade behavior

⚠️ Missing test cases:

  1. Error scenarios: What happens when projection tree creation fails?
  2. Cancellation: Testing .cancel() mid-animation
  3. Rapid mutations: Multiple animateLayout calls in quick succession
  4. Performance: Tests with large DOM trees (100+ elements)
  5. Accessibility: Respecting prefers-reduced-motion

📚 Documentation Suggestions

The API documentation in animate-layout.ts is good, but consider adding:

  1. Migration guide: For users of previous layout animation APIs
  2. Performance tips: When to use layout vs. transform animations
  3. Common patterns: Examples of common use cases
  4. Troubleshooting: Common issues and solutions

🎯 Summary

Overall Assessment: ⭐⭐⭐⭐½ (4.5/5)

This is a high-quality refactor with solid architecture, comprehensive tests, and good code practices. The main concerns are:

  1. Potential null pointer issue with root\! assertion
  2. Memory leak risk if animations are interrupted
  3. Some edge cases in type safety

Recommendation: ✅ Approve with minor fixes

The critical issues are fixable without major refactoring. I'd suggest addressing items #1, #2, and #3 from the recommendations above before merging.

Great work on this refactor! The builder pattern API is intuitive and the projection system integration is well thought out. 🚀

@mattgperry mattgperry closed this Jan 13, 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.

2 participants