plans/issues/issue-3173.md
AnimateSuspense (animated transitions between Suspense fallback and content)Executor instructions: This plan is decision-gated. Do NOT write feature code until the maintainer records a decision in the
plans/issues/README.mdrow for issue-3173. Run the drift check first.Drift check (run first):
gh api repos/motiondivision/motion/issues/3173 --jq .state→ expect"open".
42bfbe3ed, 2026-06-11Filed 2025-04-27, revisiting #1193 (closed, 14 comments). React 19/Next App
Router make Suspense/streaming central, but Motion has no first-class way to
animate the swap between a Suspense fallback and its resolved children —
React removes the fallback synchronously, so AnimatePresence alone can't
run its exit animation. mattgperry already signalled openness in #1193:
"<AnimateSuspense fallback={fallback}>{children}</AnimateSuspense>" (same
signature as Suspense). The reporter has a working prototype and offered to
contribute. The community workaround (lpic10's sandbox hy3yvq — CodeSandbox
is Cloudflare-gated, could not be fetched at planning time) keeps the fallback
mounted outside the boundary and toggles it from inside.
Record ONE in the README row:
AnimateSuspense in this repo (design below).Questions the decision should settle (list them in the row or a comment):
framer-motion React layer
(packages/framer-motion/src/components/AnimateSuspense/) — sibling of
AnimatePresence — and re-export via motion/react?useDeferredValue/transition
semantics — React 18+, with React 19 streaming as the headline use case.mode ("wait" | "sync" | "popLayout") like
AnimatePresence, or start with "wait" only (recommended: "wait" only,
smallest API surface)?initial={false}.)Signature mirrors Suspense:
<AnimateSuspense fallback={<Skeleton />}>{children}</AnimateSuspense>
Mechanism (the only robust pattern without <Activity>/unstable APIs):
<Suspense> whose children are the user's
children plus a zero-size "resolved" probe — a component that mounts
only when the boundary's children have resolved and reports via state
(useEffect on mount/unmount → setResolved(true/false)).fallback OUTSIDE the Suspense, wrapped in
<AnimatePresence>, keyed and conditionally rendered on !resolved —
so its exit animation can run after content resolves.Suspense fallback prop is null (or the probe's inverse), so
React never hard-swaps visible DOM; visual swap is fully owned by
AnimatePresence. Content enters via a motion wrapper (or accepts user
motion children with initial/animate).resolved=false → fallback re-enters. Must verify behaviour when
startTransition keeps stale content visible (probe should NOT unmount
during transitions — that's correct UX).PresenceContext/AnimatePresence internals — no new presence
machinery. Files: packages/framer-motion/src/components/AnimateSuspense/index.tsx
(+ types.ts), export from packages/framer-motion/src/index.ts and the
motion package's react entry (packages/motion/src/react.ts — verify
exact entry file at implementation time).Known risks to spike before committing to the API:
mode="wait", problematic for "sync".use()/streaming hydration: fallback present in server HTML;
ensure no hydration mismatch when client immediately animates it out.Build the probe pattern in dev/react/src/tests/animate-suspense.tsx using
React.lazy with a controllable delay; verify fallback exit + content enter
in the browser. Per CLAUDE.md: Suspense/React.lazy behaviour MUST be tested
with Cypress, not JSDOM.
Implement AnimateSuspense as designed; no default exports; interface for
props; keep bundle impact minimal (compose AnimatePresence, don't fork it).
packages/framer-motion/cypress/integration/animate-suspense.ts
— fallback animates out (opacity mid-exit check via .then(), long linear
duration), content animates in; re-suspension cycle. Run on React 18 AND 19
per the CLAUDE.md recipe.packages/framer-motion/src/components/__tests__/
(renders fallback markup without crashing; initial={false} semantics).CHANGELOG.md "Added" entry; PR references #3173 and #1193; invite the reporter's review since they offered a prototype.
yarn build + yarn lint exit 0