vercel / next.js

The React Framework
https://nextjs.org
MIT License
127.34k stars 27.02k forks source link

[NEXT-1151] App router issue with Framer Motion shared layout animations #49279

Open maurocolella opened 1 year ago

maurocolella commented 1 year ago

Verify canary release

Provide environment information

Operating System:
      Platform: linux
      Arch: x64
      Version: #22 SMP Tue Jan 10 18:39:00 UTC 2023
    Binaries:
      Node: 16.17.0
      npm: 8.15.0
      Yarn: 1.22.19
      pnpm: 7.1.0
    Relevant packages:
      next: 13.4.1-canary.1
      eslint-config-next: 13.0.7
      react: 18.2.0
      react-dom: 18.2.0

Which area(s) of Next.js are affected? (leave empty if unsure)

App directory (appDir: true)

Link to the code that reproduces this issue

https://codesandbox.io/p/sandbox/stupefied-browser-tlwo8y?file=%2FREADME.md

To Reproduce

I provided a larger repro for context, as it is unclear which combination of factors leads to the specific bug, although a number of other people report the same issue.

Describe the Bug

Framer Motion supports a feature called shared layout animation that automatically transitions components whose styles have changed when the container (that contains them) re-renders.

This feature appears not to be working in multiple scenarios with Next.js 13 under the app folder.

In the provided example, this feature is applied to the blue navigation highlight.

The affected container in the code sandbox is: https://codesandbox.io/p/sandbox/stupefied-browser-tlwo8y?file=%2Flib%2Fcomponents%2FNavigation.tsx

To produce the undesired behavior, I simply applied layoutId as specified in the relevant Framer Motion documentation to the motion elements expected to transition.

Framer Motion 5 removes the AnimateSharedLayout component. Now, you can use the layoutId prop and components will animate from one to another without the need for the AnimateSharedLayout wrapper.

I believe I also tried more explicit variations. Others have reported similar or identical issues in the bug currently open with Framer Motion.

Expected Behavior

I expect the blue highlight to slide smoothly to its new position when the nav container re-renders.

Which browser are you using? (if relevant)

Version 113.0.5672.63 (Official Build) (64-bit)

How are you deploying your application? (if relevant)

Usually Vercel

NEXT-1151

joshdavenport commented 1 year ago

As an extra point for this issue, the docs specify that using templates is a way to achieve enter/exit animations (web archive link, see edit) with either CSS or an animation library (for which framer-motion would be one of the go-to choices for most I feel) but doesn't offer any indication as to how to achieve this.

With templates, even if you wrap children in layout (where templates is rendered, and even given a key) transitions just don't work because there's no way to make that template a framer motion element with animate props.

Edit: This wording is now removed, just linking the docs as they were at this moment in time so it doesn't look like I was saying the docs say something it doesn't: web archive link

zackdotcomputer commented 1 year ago

I believe I've pinpointed the issue that is causing this problem. As part of the new app router structure, Next lays out a tree of components that includes the following loop (rendering a hypothetical /path/subpath request):

- Root Layout
  - OuterLayoutRouter
    - TemplateContext.Provider key="path"
      - Root Template
        - InnerLayoutRouter
          - Path Layout
            - OuterLayoutRouter
              - TemplateContext.Provider key="subpath"
                - Path Template (not subpath, as that is a page.tsx)
                  - InnerLayoutRouter
                    - OuterLayoutRouter
                      - TemplateContext.Provider key="__PAGE__"
                        - Page contents

Crucially, the app framework is inserting an OuterLayoutRouter component between each Layout and corresponding Template. This component is what is responsible for performing the content swap when the user navigates to a new path. The system seems to select the lowest OuterLayoutRouter that can be used (e.g. for the path navigation /sign-up/step1 to /sign-up/step2 the second OuterLayoutRouter, which represents the directory /sign-up and lives inside any layout.tsx for that directory, would be used. If the paths were /sign-up/step/1 to /sign-up/step/2, then the third OuterLayoutRouter would be used. And so on...).

Because the OuterLayoutRouter fully swaps its contents to the new path, and because those contents include the template.tsx, the template cannot provide an "on exit" effect (as it will have been pruned from the tree already). Because the OuterLayoutRouter itself does not have a key (because it is not swapped), the layout cannot include an AnimatePresence because that component requires its direct child have a key that indicates navigation. Finally, because the usePathname function only updates after the render swap has been performed, one cannot sneak a key into the stack. If one tries to hack in an exit-enter effect by making a layout that uses the pathname to indicate when its child has changed, then the contents will be swapped to the new page and then fade-out and fade-back-in.

The potential fixes I see for this issue are:

  1. Next's render stack could be changed so that the Layout and Template are rendered in an immediate parent/child relationship, which TBH is what the documentation says should happen (I'll open a separate bug for this)
  2. Next could expose an API or event that indicates when a navigation begins and what the destination path is, so that one could add a key to the layout when loading begins.

I don't think there is a way with the APIs currently exposed to solve this issue on Framer's side without at least some change from Next.

jamesvclements commented 1 year ago

Just adding a +1 for this to be looked at soon, there's a lot of conversation about this in the Next.js discord as well

alainkaiser commented 1 year ago

Would also love if you guys could have a look at it soon. A good amount of information already present in the framer-motion thread:

https://github.com/framer/motion/issues/1850

timneutkens commented 1 year ago

@seantai @jamesvclements @alainkaiser Please do not ping the thread with comments that do not add value. The issue is already synced into our tracker, there's hundreds of issues to be investigated and spamming issues demanding for it to be looked at is not the way to get us to look into it any faster, on the contrary, by posting these comments you're actively taking time away from us investigating / fixing issues. If all you want to do is "increase priority" you can use the 👍 on the initial post (not this post) to convey that you're running into it too.

Or you can focus your efforts on investigating / providing context on what might be causing the issue like the great comment @zackdotcomputer added.

lmatteis commented 1 year ago

Wouldn't an easier approach simply be to let us choose where to put the key in the tree? Then we can have more fine grained control over when things are re-mounted.

As an example right now two different pages that return the same component in the same position will get remounted:

// app/foo/page.js
export default function Page() {
  return <Counter />;
}

// app/bar/page.js
export default function Page() {
  return <Counter />;
}

These two counter's state will be lost when soft-navigating between /foo and /bar.

By removing the key props from the tree that Next builds, we can decide ourselves if we want to remount things:

// app/foo/page.js
export default function Page() {
  const pathname = usePathname();
  return <Counter key={pathname} />; // <-- don't preserve the counter state on soft-navigations
}

This would of course allow us to properly adjust exit-animations, as well as other more fine-grained things we want to happen when soft-navigating.

Piglow19 commented 1 year ago

Hello, Any update ?

harshv5094 commented 1 year ago

I have a question, I'm new to using nextjs framework. So It's my first time using an app router but I don't know how to use framer motion in app router.

yawlad commented 1 year ago

You can't correctly use framer motion for layout animations with app router for now

Systemcluster commented 1 year ago

You can't correctly use framer motion with app router for now

Apart from layout animations, Framer Motion works perfectly fine in client components.

zackdotcomputer commented 1 year ago

Yeah to clarify, @harshv1741 - if you're looking to use Framer Motion to perform page transitions as the user navigates from page route to page route in app router, then that is what this issue is saying is broken. Because of how the NextJS team have structured their layouts feature, you can't do that right now.

If you're looking for how to use Framer Motion inside of page or specific component, then that is out of the scope of this thread to help you with - I'd suggest taking that over to Framer Motion's site and community.

valenguerra commented 1 year ago

Hi, is there any update? Or at least someone knows of another way to make an exit animation without using framer-motion?

fweth commented 1 year ago

Hello, I just wanted to add that as far as I understand, Framer Motion as well as React Transition Group use React's cloneElement on children (or whatever you put in the ref). You can create a minimal page transition in a few lines of code for the old Next.js or Remix without any extra library, would be nice to have this support also for the App Router.

Here is how a simple exit-before-enter-animation looks like in vanilla Remix (and I'd expect it to work similarly with the App Router in the future):

export default function Layout() {
  const outlet = useOutlet();
  const [cloned, setCloned] = useState(outlet);
  const href = useHref();
  const mainRef = useRef();
  useEffect(
    function () {
      mainRef.current.style.opacity = 0;
      const timeout = window.setTimeout(function () {
        mainRef.current.style.opacity = 1;
        setCloned(cloneElement(outlet));
      }, 500);
      return function () {
        window.clearTimeout(timeout);
      };
    },
    [href],
  );
  return (
    <>
      <header>
        <nav>
          <Link to="/">Home</Link>
          <Link to="/news">News</Link>
          <Link to="/about">About</Link>
        </nav>
      </header>
      <main style={{ transition: "opacity 500ms" }} ref={mainRef}>
        {cloned}
      </main>
    </>
  );
}
JasonA-work commented 1 year ago

I agree with @fweth. Even in my app using the pages router, the only thing I'm using framer motion for is page transition animations. It'll be amazing if a solution and examples can be provided for simple page transition animation for both the pages and app router. A solution with vanilla css / js / react will help reduce a good amount of bundle size.

ShahriarKh commented 1 year ago

For animating modals using parallel routes, since we use router.back() to close the modal (see nextgram example), we can set a timeout so we have enough time to render the exit animation before changing the route.

Inside the modal component:

'use client';

import css from './Modal.module.scss';
import { useCallback, useRef, useState } from 'react';
import { usePathname, useRouter } from 'next/navigation';
import { AnimatePresence, motion } from 'framer-motion';

export default function Modal() {
  const router = useRouter();
  const pathname = usePathname(); // to use pathname as motion key

  const [show, setShow] = useState(true); // to handle mounting/unmounting

  const onDismiss = useCallback(() => {
    setShow(false);
    setTimeout(() => {
      router.back();
    }, 200); // 200ms, same as transition duration (0.2)
  }, [router]);

  return (
    <AnimatePresence>
      {show && (
        <motion.div
          key={pathname}
          initial={{ opacity: 0 }}
          animate={{ opacity: 1 }}
          exit={{ opacity: 0 }}
          transition={{
            duration: 0.2,
            ease: 'easeInOut',
          }}
        >
          {/* your modal content */}
        </motion.div>
      )}
    </AnimatePresence>
  );
}

To see the complete example of creating modals with parallel routes (albeit without animations), check out Nextgram

cutsoy commented 1 year ago

The workaround I'm using memorizes the LayoutRouterContext (using useRef) and passes its down to its children. This ensures that the old route doesn't get unmounted on navigation.

Full example below:

/// layout.tsx
function FrozenRouter(props: PropsWithChildren<{}>) {
    const context = useContext(LayoutRouterContext);
    const frozen = useRef(context).current;

    return (
        <LayoutRouterContext.Provider value={frozen}>
            {props.children}
        </LayoutRouterContext.Provider>
    );
}

export default function Layout(props: PropsWithChildren<{}>) {
    const pathname = usePathname();

    return <AnimatePresence>
        <motion.div
            key={pathname}
            initial={{ opacity: 0 }}
            animate={{ opacity: 0 }}
            exit={{ opacity: 0 }}
            transition={{ duration: 0.4, type: "tween" }}
        >
            <FrozenRouter>{props.children}</FrozenRouter>
        </motion.div>
    </AnimatePresence>;
}

This is working pretty good so far. Fingers crossed it works for you too! 🤞

eldevyas commented 1 year ago

Hey, I appreciate your help. Can you add a Repo Link to the working code example or provide more information about the Context Provider Configuration?

eldevyas commented 1 year ago

I just managed to piece it together. Here's how I implemented the solution:

import React, {useContext, useRef} from "react";
import { motion, AnimatePresence } from 'framer-motion';
import { PropsWithChildren, useRef } from 'react';
import { usePathname } from 'next/navigation'; // Import your pathname utility

import { LayoutRouterContext } from "next/dist/shared/lib/app-router-context";

function FrozenRouter(props: PropsWithChildren<{}>) {
  const context = useContext(LayoutRouterContext);
  const frozen = useRef(context).current;

  return (
    <LayoutRouterContext.Provider value={frozen}>
      {props.children}
    </LayoutRouterContext.Provider>
  );
}

export default function Layout(props: PropsWithChildren<{}>) {
  const pathname = usePathname();

  return (
    <AnimatePresence>
      <motion.div
        key={pathname}
        initial={{ opacity: 0 }}
        animate={{ opacity: 1 }}
        exit={{ opacity: 0 }}
        transition={{ duration: 0.4, type: 'tween' }}
      >
        <FrozenRouter>{props.children}</FrozenRouter>
      </motion.div>
    </AnimatePresence>
  );
}
cutsoy commented 1 year ago

Almost! The LayoutRouterContext is from next.js and seems to contain all of the routing state (hence why it needs to be frozen while animating the unmount of one of the routes). So you shouldn't define it yourself. Instead, just import it from next.

import { LayoutRouterContext } from "next/dist/shared/lib/app-router-context";

I will try to share an example of how to use it with nextgram tomorrow.

lmatteis commented 1 year ago

Almost! The LayoutRouterContext is from next.js and seems to contain all of the routing state (hence why it needs to be frozen while animating the unmount of one of the routes). So you shouldn't define it yourself. Instead, just import it from next.

import { LayoutRouterContext } from "next/dist/shared/lib/app-router-context";

I will try to share an example of how to use it with nextgram tomorrow.

This actually works. Any reason why the API isn't public?

leerob commented 1 year ago

A few questions we'd love to hear more feedback on here:

kylemh commented 1 year ago

Let me know if I'm way off base here, but this thread - I don't think - has anything to do with things like nProgress. It's more like transitioning within a layout between routes.

Easy to imagine with modal animations or fade-in and -out of previous to next route. Basically, all the example sites you see with the Chrome View Transitions API (but Framer Motion specifically)

Essentially, in the pages router, the AnimatePresence component worked flawlessly. Now, it's not viable and it seems much more difficult to do complex, staggered, or JS-heavy animations between routes of the same layout component.

leerob commented 1 year ago

@kylemh I gave that example just because this comes up in the same conversation of "why are there not router events in the App Router?", similar to animating page transitions.

Systemcluster commented 1 year ago

A few questions we'd love to hear more feedback on here:

I'll answer in the context of the nProgress use-case here; Whether or not this applies to shared layout animations is up for discussion.

Are you wanting to show a loading indicator like nprogress for every navigation?

Every top-level navigation, where the URL changes.

Would you want to show this every time a Server Actions happens, as well? (a data mutation)

No. At least not without clear separation of the events.

What would you want to happen when starting other React transitions?

  • e.g. If there's a Suspense boundary on the second page, should it stop when the loading state of the Suspense boundary is shown or when the last one completes?

I'm using Suspense in app/(app)/layout.tsx (inner layout is a 'use client' component), and I'd expect the routing to not resolve before the Suspense in the layout resolves. But I would expect it to complete before Suspense inside pages resolve. So the first non-layout page in the hierarchy would be a boundary.

fweth commented 1 year ago

Just one question, does the FrozenRouter component also delay the data fetching? It shouldn't matter much, but ideally the fetch starts right away and only the DOM waits for the animation before removing the tree...

remorses commented 1 year ago

FrozenRouter Is an hack to keep the segments tree data the same between client navigations, I would not use it in a production app.

To make Next App router work with framer motion page animations Next.js would need to add a way to keep the layouts key prop stable between navigations (what the FrozenRouter actually does in an hacky way)

fweth commented 1 year ago

Thanks! I wonder what is going in with the children of a layout component, they seem to behave different from the children of a vanilla React component. Here, in this example, the <Translate> component doesn't need to know anthing about its children, needs no key prop, and still manages to animate the transitions. But for some reason, this approach doesn't work with Next.js and layouts. Especially the freezing part, it seems as if on route change, Next.js somehow doesn't hand the new children to the layout component and let it render them, but replaces the children further below in the React tree without the layout component having a chance to intercept.

import { cloneElement, useEffect, useRef, useState } from "react";

function Transition({ children }) {
  const [cloned, setCloned] = useState(cloneElement(children));
  const divRef = useRef();
  const firstRun = useRef(true);
  useEffect(
    function () {
      if (firstRun.current) {
        firstRun.current = false;
        return;
      }
      divRef.current.style.opacity = 0;
      let timeout = window.setTimeout(function () {
        divRef.current.style.opacity = 1;
        setCloned(cloneElement(children));
      }, 400);
      return function () {
        window.clearTimeout(timeout);
      };
    },
    [children, setCloned]
  );
  return (
    <div style={{ transition: "opacity 400ms" }} ref={divRef}>
      {cloned}
    </div>
  );
}

export default function App() {
  const [toggled, setToggled] = useState(false);
  return (
    <div className="App">
      <button
        onClick={function () {
          setToggled(!toggled);
        }}
      >
        Click me!
      </button>
      <Transition>
        <h1>{toggled ? "Flip" : "Flop"}</h1>
      </Transition>
    </div>
  );
}
leerob commented 1 year ago

This is helpful! So to clarify, are y'all looking for the navigation end event, or the start event?

You can already get the end event as follows, replacing the contents that run in the effect:

'use client'

import { usePathname } from 'next/navigation'

export default function Nav() {
  const pathname = usePathname()
  useEffect(() => '...', [path])
  return '...'
}
JasonA-work commented 1 year ago

For me personally, I just want to have an easy implementation that allows me to apply some simple css transitions to the parts of the route that change (children in the layout). Just something simple like this:

.children[before-route-change] {
    opacity: 1;
    transition: opacity 350ms ease;
}

.children[during-route-change] {
    opacity: 0;
    //This needs to happen immediately after the user clicks a <Link> so that the UX feels natural.
}

.children[after-route-change-is-complete] {
    opacity: 1;
    //At this point in time, we can use Suspense boundaries / loading.tsx to render a loading state
    //if data still needs to be fetched
}

I'm not sure if this is already possible at the moment. If it is, please advise!

To me, whether it's a vanilla css implementation or framer implementation doesn't matter as much as just being able to do it. (In fact, it's even better if I don't need to use framer to achieve this. That way I won't even need to include it into the bundle).

joshmeney commented 1 year ago

This is helpful! So to clarify, are y'all looking for the navigation end event, or the start event?

Personally I'm following this thread as I'm awaiting a starting event and error/interruption event.

fweth commented 1 year ago

To me, whether it's a vanilla css implementation or framer implementation doesn't matter as much as just being able to do it. (In fact, it's even better if I don't need to use framer to achieve this. That way I won't even need to include it into the bundle).

It seems that the behaviour is currently broken for all implementations, but in general, there is also React Transition Group which should come with a smaller bundle size than Framer Motion.

murrowblue22 commented 1 year ago

so what nothing ain't no fix coming for this cause, my app needs these page transitions

ShueiYang commented 1 year ago

What would you want to happen when starting other React transitions?

If there's a Suspense boundary on the second page, should it stop when the loading state of the Suspense boundary is shown or when the last one completes?

I am using the loading.tsx in the app router, I don't mind after the Exit animation complete on the 1st page, the enter animation start right and finish at this loading page. (if the Suspence boundary deed happen on the 2nd page) Right now the hacky solution with FrozenRouter work but if there is a suspense it will block on the loading page.

vermeerenmaxime commented 1 year ago

Following

mashaole commented 1 year ago

this might help with the framer motion problem for most people who want to still use server components and framer motion animation together

// ./libs/framer-motion.ts
'use client'

import {motion,AnimatePresence} from 'framer-motion'
export const div=motion.div;
export const h1=motion.h1;
export const h2=motion.h2;
export const h3=motion.h3;
export const span=motion.span;
export const p=motion.p;
export const button=motion.button;
export const Anime=AnimatePresence;

i have a component called BasePage thats a child of app/layout.tsx its a wrapper for all my pages

//./app/components/BasePage.tsx

import React from "react";
import PropTypes from "prop-types";
import * as motion from "@/app/libraries/framer-motion";
const BaseAppBar = React.lazy(() => import("@/app/components/Navigation/Appbar"));
const SideNavBar = React.lazy(() => import("@/app/components/Navigation/SideNav"));
const Footer  = React.lazy(() => import("@/app/components/Navigation/Footer"));

export default function BasePage(props:any) {
  return (
    <>
      {props.hasAppBar ? (
        <motion.div>
          <BaseAppBar />
        </motion.div>
      ) : (
        <></>
      )}
      {props.hasSideNav ? (
        <div >
          <SideNavBar />{" "}
        </div>
      ) : (
        <></>
      )}
      <main>
        <motion.Anime mode="popLayout">
      <motion.div 
      initial={{opacity:0,y:20}}
      animate={{opacity:1,y:0}}
      exit={{opacity:0,y:20}}
      >
      {props.child}
      </motion.div>
      </motion.Anime>
      </main>
      <Footer />
    </>
  );
}
JP-HoneyBadger commented 1 year ago

Following

npostulart commented 1 year ago

I had the same issue lately so I took the input given in this discussion and made a little demo using the nextgram demo as a base. For everyone interested you can find it here https://github.com/npostulart/nextgram-with-page-transitions

Hope we don't need that hacky workaround in the future.

minusplusmultiply commented 1 year ago

Thanks @npostulart! This is really helpful!

Are we generally of the opinion that this is unlikely to be solved until the View Transitions API has better/broader integration/browser support? (With Vercel opting for a more "native" implementation?)

npostulart commented 1 year ago

This is helpful! So to clarify, are y'all looking for the navigation end event, or the start event?

@leerob I can only talk about myself, but the main issue seem to be that the page content get's updated as soon as the page is loaded and therefore already displayed in the exit animated content. The workaround with the frozen router context seem to work for now but also seems like a hacky solution.

npostulart commented 1 year ago

It looks like the update to 13.5 breaks the workaround with the frozen router:

Warning: Detected multiple renderers concurrently rendering the same context provider. This is currently unsupported.
    at FrozenRouter (webpack-internal:///(ssr)/./src/components/utils/FrozenRouter.tsx:14:25)

It seems to still work fine though. But you have to change the import of the LayoutRouterContext to

import { LayoutRouterContext } from 'next/dist/shared/lib/app-router-context.shared-runtime';
Frumba commented 1 year ago

Thanks @npostulart ! It works pretty well ! There is one thing that is not working though, this is the fast refresh. When I do any changes on classNames in children of the frozen router in dev mode nothing is being refreshed unless i reload the page. Did you encounter this issue ? Thanks !

joshdavenport commented 1 year ago

Seems to be a limitation of the approach, cdebotton also discovered the same behaviour in the related issue over in the framer motion repo. Probably without some kind of first class support (no idea what that actually looks like) for this use case it's unavoidable.

npostulart commented 1 year ago

Thanks @npostulart ! It works pretty well ! There is one thing that is not working though, this is the fast refresh. When I do any changes on classNames in children of the frozen router in dev mode nothing is being refreshed unless i reload the page. Did you encounter this issue ? Thanks !

@Frumba I'm facing the same issue. Can't confirm it but I'm assuming due to the FrozenRouter the content of the page doesn't get updated.

tushargoyalofficial commented 1 year ago

@npostulart your solution working as expected in dev mode. But today when I created production build and hosted on vercel, none of animation is working. Only animations done by tailwind css working fine, framer motion not. Trying to find solution but not able to get any.

npostulart commented 1 year ago

@tushargoyalofficial sorry to hear that. Without further information I can’t help you out. Also I would like to clarify that this is a temporary workaround and should be used with caution. I still hope we will get some help from the Next.js team soon to have a more mature and solid solution.

tushargoyalofficial commented 1 year ago

Thanks, i had shifted to page routing now, app router really sucks

kschmelter13 commented 1 year ago

Has anyone found any update on this? its been like a year still nothin

Galanthus commented 1 year ago

Has anyone found any update on this? its been like a year still nothin

What a DRAMA right?

fweth commented 1 year ago

I wonder if Vercel just waits for the View Transitions API to take over all browsers.

klarstrup commented 1 year ago

I wonder if Vercel just waits for the View Transitions API to take over all browsers.

They don't seem to be about to provide appropriate affordances to use startViewTransition with Next.js routing either: https://github.com/vercel/next.js/discussions/46300