Add AnimateSuspense component for animated suspense fallback transitions#3714
Add AnimateSuspense component for animated suspense fallback transitions#3714mattgperry wants to merge 1 commit intomainfrom
Conversation
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 SummaryThis PR introduces
Confidence Score: 3/5The 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
Important Files Changed
Sequence DiagramsequenceDiagram
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
Reviews (1): Last reviewed commit: "Add AnimateSuspense component" | Re-trigger Greptile |
| * </AnimateSuspense> | ||
| * ``` | ||
| */ | ||
| fallback: ReactNode |
There was a problem hiding this comment.
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.
| fallback: ReactNode | |
| fallback: React.ReactElement |
| 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> | ||
| </> | ||
| ) | ||
| } |
There was a problem hiding this comment.
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.
Summary
Adds a new
<AnimateSuspense>component that mirrors React's<Suspense>API but allows thefallbackto animate out (via Motion'sexitprop) 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.Design
Sentinel-based: an empty
SuspenseTrackeris passed as React'sSuspensefallback, and aResolveTrackerwraps the children. Their layout-effect mount/unmount togglesisSuspendedstate, which an internalAnimatePresenceconsumes to render and animate the user-supplied fallback.isSuspendedinitialises tofalse. The fallback only renders when React actually suspends and mounts theSuspenseTracker— 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:
isSuspendedstarts asfalse, nottrue, so already-resolved children never briefly render the fallback. Covered by the newmode=syncCypress and Jest tests.[]and readsetIsSuspendedthrough a ref, so they cannot fire spuriously if the setter is ever wrapped.customprop forwarded —AnimateSuspensePropsnow exposescustom, matchingAnimatePresence.modeis"sync"— matchesAnimatePresence's default rather than overriding to"wait".Test plan
AnimateSuspense(rendering with non-suspending and suspending children, prop-shape compatibility withSuspense). Full 803-test suite passes.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