Skip to content

[BUG] Variant propagation lost for children that mount asynchronously via React.lazy + Suspense #3562

@jthrilly

Description

@jthrilly

1. Read the FAQs

Done.

2. Describe the bug

When a parent motion.div uses variant propagation (initial="initial" / animate="animate" with variants) and when: "beforeChildren", descendant motion.* components that rely on propagated variants (i.e. they define variants but no explicit initial/animate) will skip their enter animation if they mount asynchronously — for example, when loaded via React.lazy() inside a <Suspense> boundary.

The parent's initialanimate transition starts (and often completes) before the lazy-loaded descendants mount. By the time they appear in the tree, the parent's MotionContext already carries the animate state, so the children resolve directly to their animate variant values without any transition.

On subsequent renders where the lazy module is cached (e.g. navigating away and back), the children mount synchronously and the animation works correctly. This makes the bug appear intermittent — it only manifests on first load when the module hasn't been fetched yet.

This is related to but distinct from #2269 (animations freezing when a child suspends mid-animation). This issue is specifically about variant propagation to descendants that mount in a later React commit than their animating parent.

3. CodeSandbox reproduction

Minimal reproduction code:

import { motion, AnimatePresence } from "motion/react";
import { lazy, Suspense, useState, useLayoutEffect } from "react";

// Simulate a lazy-loaded component with descendants that use variant propagation
const LazyChild = lazy(
  () =>
    new Promise<{ default: React.ComponentType }>((resolve) => {
      // Small delay to simulate module fetch — even 1ms is enough
      // because it forces React to commit in a separate microtask
      setTimeout(() => {
        resolve({
          default: () => {
            const childVariants = {
              initial: { opacity: 0, y: 20 },
              animate: {
                opacity: 1,
                y: 0,
                transition: { duration: 0.5 },
              },
            };

            return (
              <motion.div variants={childVariants}>
                <p>
                  I should fade/slide in, but on first load I appear instantly.
                </p>
              </motion.div>
            );
          },
        });
      }, 50);
    })
);

const parentVariants = {
  initial: { opacity: 0 },
  animate: {
    opacity: 1,
    transition: { when: "beforeChildren", duration: 0.3 },
  },
  exit: {
    opacity: 0,
    transition: { when: "afterChildren", duration: 0.3 },
  },
};

export default function App() {
  const [show, setShow] = useState(false);

  // Defer to trigger "enter" rather than "appear" in AnimatePresence
  useLayoutEffect(() => {
    setShow(true);
  }, []);

  return (
    <div>
      <AnimatePresence mode="wait">
        {show && (
          <motion.div
            key="stage"
            initial="initial"
            animate="animate"
            exit="exit"
            variants={parentVariants}
          >
            <Suspense fallback={<div>Loading...</div>}>
              <LazyChild />
            </Suspense>
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  );
}

4. Steps to reproduce

  1. Use code above
  2. Add a button that toggles show off and on. After toggling, the lazy module is cached, and the child correctly animates in on re-entry

5. Expected behavior

Descendant motion.* components that define variants matching the parent's variant names should animate from initial to animate even if they mount asynchronously after the parent. The when: "beforeChildren" orchestration should wait for all children — including those resolved by Suspense — before beginning the parent-to-children animation sequence.

Alternatively, if late-mounting children cannot participate in the parent's original orchestration, they should at least detect that they've "missed" the initial state and play their initialanimate transition independently upon mount.

6. Video or screenshots

N/A — the effect is that on first page load, the child component appears instantly at full opacity instead of fading/sliding in.

7. Environment details

  • motion 12.34.1
  • React 19
  • Next.js 15 (App Router with code-splitting via React.lazy)
  • Chrome / macOS

What is the intended pattern for variant propagation when descendants mount asynchronously (e.g. behind a <Suspense> boundary with React.lazy)?

Our current workaround is to defer the parent's animation by holding animate="initial" until a signal component inside the <Suspense> boundary fires via useLayoutEffect, then switching to animate="animate". This ensures all children are mounted before the transition begins. But this feels like it should be handled by the library, especially given that when: "beforeChildren" already expresses the intent to sequence parent and child animations.

Would it be feasible for Motion to:

  1. Detect that a Suspense boundary exists between the parent and propagated children, and defer orchestration until the boundary resolves?
  2. Or, allow late-mounting children to "catch up" by playing their variant transition from initial when they first mount into an already-animate context?

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions