Skip to content

Add AnimateSuspense component for animated suspense fallback transitions#3714

Closed
mattgperry wants to merge 1 commit intomainfrom
fix/issue-3173-animate-suspense
Closed

Add AnimateSuspense component for animated suspense fallback transitions#3714
mattgperry wants to merge 1 commit intomainfrom
fix/issue-3173-animate-suspense

Conversation

@mattgperry
Copy link
Copy Markdown
Collaborator

Summary

Adds a new <AnimateSuspense> component that mirrors React's <Suspense> API but allows the fallback to animate out (via Motion's exit prop) once suspended children resolve. The primary use case is React.lazy / streaming content where users want to cross-fade or otherwise transition between a loading state and the resolved content.

import { motion, AnimateSuspense } from "framer-motion"

<AnimateSuspense
  fallback={
    <motion.div
      initial={{ opacity: 0 }}
      animate={{ opacity: 1 }}
      exit={{ opacity: 0 }}
    >
      Loading...
    </motion.div>
  }
>
  <LazyContent />
</AnimateSuspense>

Design

Sentinel-based: an empty SuspenseTracker is passed as React's Suspense fallback, and a ResolveTracker wraps the children. Their layout-effect mount/unmount toggles isSuspended state, which an internal AnimatePresence consumes to render and animate the user-supplied fallback.

isSuspended initialises to false. The fallback only renders when React actually suspends and mounts the SuspenseTracker — non-suspending children render directly without a fallback flash.

Differences from the previous attempt (#3610)

That PR was closed unmerged after greptile flagged several issues. This iteration addresses them:

  • No initial-flash regressionisSuspended starts as false, not true, so already-resolved children never briefly render the fallback. Covered by the new mode=sync Cypress and Jest tests.
  • Ref-based tracker callback — the tracker effects depend on [] and read setIsSuspended through a ref, so they cannot fire spuriously if the setter is ever wrapped.
  • custom prop forwardedAnimateSuspenseProps now exposes custom, matching AnimatePresence.
  • Default mode is "sync" — matches AnimatePresence's default rather than overriding to "wait".

Test plan

  • Jest: 3 new tests for AnimateSuspense (rendering with non-suspending and suspending children, prop-shape compatibility with Suspense). Full 803-test suite passes.
  • Cypress (React 18 + React 19): two specs — one verifying the fallback enters, content resolves, and the fallback exits; one verifying the fallback never mounts when children don't suspend.
  • Regression-checked by temporarily reverting useState(false)useState(true): the "does not render fallback when children do not suspend" spec fails, confirming the test catches the prior bug.

Fixes #3173

🤖 Generated with Claude Code

Adds a new <AnimateSuspense> component that mirrors React's <Suspense>
API but allows the fallback to animate out (via motion's exit prop) when
suspended children resolve.

The component uses a sentinel-based design: an empty SuspenseTracker is
used as React's Suspense fallback, and a ResolveTracker wraps children.
Their layout-effect mount/unmount toggles `isSuspended` state, which an
inner AnimatePresence consumes to render and exit-animate the fallback.

isSuspended initialises to `false` so non-suspending children never
trigger a fallback flash on initial render — only an actual React
suspension brings the SuspenseTracker into the tree.

Fixes #3173

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

greptile-apps Bot commented May 11, 2026

Greptile Summary

This PR introduces AnimateSuspense, a new public component that wraps React's <Suspense> to enable animated entry/exit transitions on the loading fallback using AnimatePresence and motion's exit prop. The implementation uses a sentinel-based approach: SuspenseTracker (rendered as the native Suspense fallback) and ResolveTracker (wrapping actual children) toggle an isSuspended boolean via layout effects, which AnimatePresence consumes to drive the user's animated fallback.

  • Core mechanism: SuspenseTracker mounts on suspension (isSuspended=true) and unmounts on resolution (isSuspended=false); ResolveTracker calls setRef.current(false) on mount as a safety net for non-suspending children, ensuring no fallback flash.
  • Type mismatch on fallback: fallback is typed as ReactNode (matching React.Suspense) but only ReactElement values are displayed — AnimatePresence internally filters via onlyElements/isValidElement, silently dropping strings, numbers, and booleans.
  • SSR/streaming gap: The native Suspense fallback slot is occupied by SuspenseTracker (renders null), so the user's fallback is invisible during SSR streaming and only appears after client-side effects fire.

Confidence Score: 3/5

The component works correctly for the primary use case (ReactElement fallbacks with motion components), but there is a behavioral gap for non-element fallbacks that silently renders nothing during suspension.

The fallback: ReactNode type promises compatibility with React.Suspense's API, but passing a string or number as fallback results in a completely empty loading state with no error or warning. Additionally, the SSR streaming behavior leaves the fallback invisible on server-rendered pages until client effects fire, which is a meaningful limitation for SSR-heavy apps that is currently undocumented.

packages/framer-motion/src/components/AnimateSuspense/types.ts needs the fallback type tightened; packages/framer-motion/src/components/AnimateSuspense/index.tsx would benefit from a JSDoc note on SSR behavior.

Important Files Changed

Filename Overview
packages/framer-motion/src/components/AnimateSuspense/index.tsx Core implementation of AnimateSuspense using sentinel components (SuspenseTracker/ResolveTracker) and AnimatePresence; SSR fallback is invisible on first paint due to effect timing.
packages/framer-motion/src/components/AnimateSuspense/types.ts Props type uses ReactNode for fallback but only ReactElement values render; non-element fallbacks are silently dropped by AnimatePresence's onlyElements filter.
packages/framer-motion/src/components/AnimateSuspense/tests/AnimateSuspense.test.tsx Three Jest tests covering non-suspending render, suspending render, and prop-shape compatibility; no test for non-element fallback (string/number) edge case.
packages/framer-motion/cypress/integration/animate-suspense.ts Two Cypress specs covering suspend/resolve flow and no-flash for sync children; uses __fallbackMountCount sentinel on window for reliable assertion.
packages/framer-motion/src/index.ts Exports AnimateSuspense component and AnimateSuspenseProps type from the package root.
dev/react/src/tests/animate-suspense.tsx Dev-environment test harness providing ?mode=suspend and ?mode=sync variants used by the Cypress integration tests.

Sequence Diagram

sequenceDiagram
    participant AP as AnimateSuspense
    participant S as React Suspense
    participant ST as SuspenseTracker
    participant RT as ResolveTracker
    participant APC as AnimatePresence

    AP->>S: "render(fallback=SuspenseTracker, children=ResolveTracker)"
    Note over S,RT: Children suspend (e.g. React.lazy)
    S->>ST: mount SuspenseTracker
    ST-->>AP: "setRef.current(true) isSuspended=true"
    AP->>APC: render(fallback element)
    APC-->>AP: fallback animates IN

    Note over S,RT: Lazy import resolves
    S->>ST: unmount SuspenseTracker
    ST-->>AP: "cleanup setRef.current(false) isSuspended=false"
    S->>RT: mount ResolveTracker + children
    RT-->>AP: setRef.current(false) no-op
    AP->>APC: render(null)
    APC-->>AP: fallback animates OUT
    Note over APC: onExitComplete() fires after exit
Loading

Reviews (1): Last reviewed commit: "Add AnimateSuspense component" | Re-trigger Greptile

* </AnimateSuspense>
* ```
*/
fallback: ReactNode
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 The fallback prop is typed as ReactNode, which mirrors React.Suspense's signature but silently fails here. Because AnimateSuspense replaces the native Suspense fallback with SuspenseTracker (which renders null) and routes the user's fallback through AnimatePresence, only ReactElement values are ever displayed — AnimatePresence calls onlyElements() internally, which uses isValidElement to filter out strings, numbers, booleans, and fragments with non-element children. Passing fallback="Loading…" results in a completely blank screen during suspension with no warning.

Suggested change
fallback: ReactNode
fallback: React.ReactElement

Comment on lines +76 to +102
onExitComplete,
}: AnimateSuspenseProps) {
const [isSuspended, setIsSuspended] = useState(false)

/**
* Stable ref-based callback so the tracker effects only depend on `[]`
* and never re-fire if `setIsSuspended` is ever wrapped by an HOC.
*/
const setRef = useRef(setIsSuspended)
setRef.current = setIsSuspended

return (
<>
<Suspense fallback={<SuspenseTracker setRef={setRef} />}>
<ResolveTracker setRef={setRef}>{children}</ResolveTracker>
</Suspense>
<AnimatePresence
custom={custom}
initial={initial}
mode={mode}
onExitComplete={onExitComplete}
>
{isSuspended ? fallback : null}
</AnimatePresence>
</>
)
}
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 SSR / streaming: user fallback is invisible on first paint

During server-side rendering, useIsomorphicLayoutEffect falls back to useEffect. The Suspense boundary shows <SuspenseTracker /> (renders null), so the streamed HTML contains nothing where the fallback should be. The user's fallback only appears after effects fire on the client — two frames after hydration. For apps using React's streaming SSR or <Suspense> for data-fetching (not just code-splitting), this means the loading state is invisible during the critical first paint, then flashes in. This limitation is worth documenting in the JSDoc or README.

@mattgperry mattgperry closed this May 11, 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.

[FEATURE] AnimateSuspense for transitions between suspense fallbacks and streamed content

1 participant