Skip to content

This issue was moved to a discussion.

You can continue the conversation there. Go to discussion →

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[v6] First-class animation primitives #7946

Closed
mjackson opened this issue Aug 13, 2021 · 28 comments
Closed

[v6] First-class animation primitives #7946

mjackson opened this issue Aug 13, 2021 · 28 comments

Comments

@mjackson
Copy link
Member

mjackson commented Aug 13, 2021

What is the new or updated feature that you are suggesting?

React Router v6 should include first-class primitives for supporting animations on route transitions.

A bit of history here: in v4/5, we added the <Switch location> prop. This prop provided a way for you to keep the <Switch> rendering an old location when it is rendered inside another element with a key that is used to perform an animation, such as a <CSSTransition> from react-transition-group. It looked something like this:

function App() {
  let location = useLocation();
  return (
    <TransitionGroup>
      <CSSTransition key={location.key}>
        <Switch location={location}>
          {/* routes go here */}
        </Switch>
      </CSSTransition>
    </TransitionGroup>
  );
}

This API worked fine for v4 and v5, but it's not entirely clear outside of the context of animations that the <Switch location> prop is to be used for animations. It was proposed in #7117 that we add a <Routes location> prop that could be used in v6. However, since v6 is built entirely on React hooks, I think we can do better.

Instead of a location prop, we can provide "animated" versions of all components and hooks that give you elements to render. So, for example, the animated version of <Routes> would be <AnimatedRoutes>. You could use it exactly like <Switch location>, except it's much more clear what it's for.

function App() {
  let location = useLocation();
  return (
    <TransitionGroup>
      <CSSTransition key={location.key}>
        <AnimatedRoutes>
          {/* routes go here */}
        </AnimatedRoutes>
      </CSSTransition>
    </TransitionGroup>
  );
}

In addition to <AnimatedRoutes> we can also provide:

  • <AnimatedOutlet>
  • useAnimatedRoutes()
  • useAnimatedOutlet()

Each of these elements and hooks would ofc work exactly like the non-animated version but with one important difference: they don't subscribe to location updates from context. Instead, they must be used inside another element with a key that will be unmounted in the transition.

AFAICT all animation libraries for React work this way. They rely on a key changing on an element to know when an element is going to transition out/in.

Why should this feature be included?

Providing animation primitives should make it easier for people to do animations between routes.

@wojtekmaj
Copy link

wojtekmaj commented Aug 18, 2021

The problem is that it does not replace <Switch location={…}> entirely. This proposal cannot replace what's been requested in #7117 and was already implemented in #7571.
That's a great example of what can be achieved with <Switch location={…}>: https://reactrouter.com/web/example/modal-gallery
And even if we could create this modal gallery with <AnimatedRoutes>, the naming seems a little awkward - we're not doing animations, we're just doing location override.

@CooCooCaCha
Copy link

Also, if react-router wants to gain traction in react-native land then you'll need to be able to support stack navigation. Please see #7943 for details.

@fgatti675
Copy link
Contributor

Hi, I hadn't seen this discussion and created a PR for adding location to Routes
#7969
My grain of salt, I don't love the idea of implementing this feature as AnimatedRoutes since it is confusing if your use case is not related to animations. Instead, having a generic Routes with a location prop that can be used for multiple cases makes a lot of sense to me :)

@timdorr
Copy link
Member

timdorr commented Aug 23, 2021

I'm not sure <AnimatedRoutes> really gets us anything. You still have to set up the useLocation hook and provide location.key to your animation components. That's just hiding the location prop, and not removing any knowledge of interacting with the current location.

Maybe a more explicit name would be clear: <Routes staticLocation={location}>

The point of the location prop for Switch/Routes is to have the route change process be controlled (much like a controlled input). While you could hack around this with a custom Router wrapping this, I think a single prop is much more straightforward. I feel like it's a reasonable API to have, but could use better naming.

@sebastian-nowak
Copy link

This update feels wrong. The Switch's location prop wasn't used for animations only, it was also used for cool things like https://reactrouter.com/web/example/modal-gallery. An API for animations isn't a substitute and is a big step backwards.

@fgatti675
Copy link
Contributor

@sebastian-nowak
I created an issue and a PR for that but got rejected unfortunately :(
#7949
For me, this is totally the way to go. Calling it staticLocation to make it more explicit sounds good

@mjackson
Copy link
Member Author

As far as I can tell, animation is the only real use case for the <Switch location> prop in v5. If there is another good use case for it I'd like to hear it.

The modal gallery that some here have pointed out is actually busted. If you refresh the page when the modal is open, you go to the actual page for that color without the modal. We should probably rebuild that example using location.state to track the modal state, not the URL.

@timdorr
Copy link
Member

timdorr commented Aug 27, 2021

Refreshing the page to have the modal go away is actually the intended behavior. It's not busted.

It's the exact same behavior used on Reddit when opening a comment thread. Within the same page load, it pops a full height modal to show the thread, leaving the thread list behind it. If you refresh the page, it's just the comment thread and no modal overlay.

This is important to browsing threads, as the order of the thread list changes rapidly. If you lose the state of the thread list when going back you would end up having threads disappear on you regularly. Retaining prior state while navigating is super important for their UX.

They use React Router and I'm not sure how they'd upgrade to v6 without a location prop.

@CooCooCaCha
Copy link

CooCooCaCha commented Aug 27, 2021

@mjackson I hope I’m not being a nuisance but I have a good bit of experience in react-native and I have concerns that v6 won’t be a viable option for react-native without the location prop.

With mobile apps, users expect to be able to use operating system gestures (or emulated gestures) to navigate around the app. The most common and important of these gestures is the swipe-back gesture.

Below is a GIF I found that hopefully illustrates this gesture clearly. As you can see, after I navigate to a new screen I need to be able to swipe that screen away and see the previous screen underneath. People call this stack-navigation because it looks like you’re swiping away cards in a stack of cards.

The tricky thing here is you should be able to have an arbitrary number of cards in the stack. So if I go from screen A -> B -> C then the stack should be [A, B, C]. As an optimization, I only need to render the two most recent cards [B, C]. However, if I swipe away C then I need to be able to render A again. I’m not sure AnimatedRoutes with its key-based solution would be able to inject the correct location back into A after it has unmounted.

It may be clearer to see code so I included a WIP implementation of this gesture in issue #7943.

Otherwise, I love the v6 API so far!

image

@fgatti675
Copy link
Contributor

My use case is similar to the modal example but a bit more complex, and related to the discussion.

I have not been able to implement the current functionality in V6, since I am missing the location prop. There might be another way but I haven't found it, so excuse me if that's the case.

You can check it if you visit our demo project (you can just skip de login)

If you open the same product url in a new window you get the same side dialog, but the base collection is now products instead of users

I could not replicate this without overriding the location prop in routes. For the rest, the V6 api is much better in my opinion and allows us to provide better routes customization to our users.

@gitcatrat
Copy link

gitcatrat commented Aug 28, 2021

If you refresh the page when the modal is open, you go to the actual page for that color without the modal.

That's exactly why I've used location prop for many years. The whole point of it for me is custom context.

I.e if you're opening a details page in modal, you're seeing it on top of feed (without losing feed position, etc) but if you send the same link to your friend, they do not need to see the feed you were scrolling, post could have been in any/personal feed.

I can't think of a better idea to build that functionality but if there's any, I'm all ears.

@gnapse
Copy link

gnapse commented Aug 28, 2021

The modal gallery that some here have pointed out is actually busted. If you refresh the page when the modal is open, you go to the actual page for that color without the modal.

We use react-router in Todoist for the settings, which are provided in a modal UI, and the modal routing works great. What we do is that we internally set a default background URL for when the modal was not triggered by the user, but the URL was visited directly. In this case, the users get the view with the settings modal open right from the start, and the background renders the default view we set. When the users close the settings modal, they land on this view as the initial one (which would've been the page they'd get anyway if they start by visiting https://todoist.com/app).

Here's a demo:

CleanShot.2021-08-28.at.18.54.58.mp4

I trigger the modal from a view called "upcoming", the URL changes fully to not encode anything about the upcoming view being visible underneath. But the upcoming view is still underneath, and all its state preserved if the user closes the settings. In the video, however, I did not close the settings, but I refreshed. You can see that in that case I no longer get the Upcoming view underneath, but the Today view instead (with the settings modal open on top). This is the view I'd get if I visited the app's root path anyway.

This is all working, inspired by the modals example in v5 documentation. So I would not call it busted.

And the approach is great because it allows us to have users open the settings for a quick tweaking, without loosing any transient state in the page underneath (e.g. they may be in the middle of adding a task that's not yet saved, etc).

@mjackson
Copy link
Member Author

Refreshing the page to have the modal go away is actually the intended behavior.

@timdorr No it isn't :) Sorry, I shouldn't have just said the modal example was busted without digging a bit further. It turns out the example code isn't actually busted, just the Codesandbox browser is. My original point stands though; the modal should still be open after a page refresh. This is because location.state persists across the full page refresh, so when you get back to that URL you should follow the exact same logic in the <ModalSwitch> component.

Here's how it should work (running in Chrome):

ModalExampleChrome

Notice how after the page refresh the modal is still open.

Here's the broken behavior I mentioned, where the modal is not still open after the page refresh (running in the Codesandbox browser in our docs):

ModalExampleCodesandbox

Notice how after the page refresh the modal isn't open anymore. That's busted. My guess is the Codesandbox browser doesn't support location.state. We should probably fix that! (either by letting the fine folks at Codesandbox know or by finding a similar product that is able to run our demos, but I digress...)

if you send the same link to your friend, they do not need to see the feed you were scrolling

@gitcatrat Yes, we agree the modal should not be there in a new window (i.e. your friend's). But if all you're doing is refreshing the existing page (same user, not your friend) you should still see the modal in our example.

But anyway, getting back to the original discussion: it's fairly easy to mimic the current modal example without the <Switch location> prop. I forked the modal example and created a simpler version that doesn't rely on it here. All you have to do is swap out the children of the route with the right elements you'd like to show when location.state.background is present. And it's simpler because you don't actually need location.state.background to be a valid location object. It could just be a simple boolean.

Retaining prior state while navigating is super important for their UX.

I put some state in the <Gallery> element @timdorr so you can see it maintains its state in the forked example.

So again, I don't think that modals are a good use case for maintaining the <Switch location> API in v6, especially when you can get the same functionality via a much simpler API.

@gnapse
Copy link

gnapse commented Aug 31, 2021

And it's simpler because you don't actually need location.state.background to be a valid location object. It could just be a simple boolean.

What if what I need to render in the background is not always the same, but actually driven by an actual location value that's not just boolean? Imagine I have a grid (gallery) view at /gallery and a list view at /list, both showing the same data with different layouts. Items in this data set open a modal when clicked. And no matter from which view I click them from, I want the modal to open on top of the view that triggered the modal.

How can we know here what background to render:

image

@mjackson
Copy link
Member Author

How can we know here what background to render

@gnapse Use a string instead of a boolean.

<Route
  path="/img/:id"
  children={
    background === "gallery" ? (
      <>
        <Gallery />
        <Modal />
      </>
    ) ? background === "list" ? (
      <>
        <List />
        <Modal />
      </>
    ) : (
      <ImageView />
    )
  }
/>

@fgatti675
Copy link
Contributor

How can we know here what background to render

@gnapse Use a string instead of a boolean.

<Route
  path="/img/:id"
  children={
    background === "gallery" ? (
      <>
        <Gallery />
        <Modal />
      </>
    ) ? background === "list" ? (
      <>
        <List />
        <Modal />
      </>
    ) : (
      <ImageView />
    )
  }
/>

So this solution is similar to what I have implemented while adapting to V6 in https://demo.firecms.co/
In my case, I don't have a limited set of components like Gallery or List but a dynamic set of components defined by the developer.
After going that road, I realised I had done the path matching myself, and didn't really need to include Routes or Route anymore, so I think it defeats the purpose.

Will all due respect @mjackson, I fail to understand the resistance to implement a feature, that basically involves adding a prop and connecting it to an internal API, and that is fixing so many issues (which are there because the prop was removed)

In my opinion, adding the location prop is a very elegant solution.

  • Easy to migrate from V5
  • It makes location management much easier, you can refactor your state to have a background string or a modal flag, but what is the benefit?
  • It solves the animations issue, all the other cases mentioned in this thread and does not need to include a new AnimatedRoutes, and all the use cases we are not aware of now.
  • What is disadvantage?

I was really excited with all the new v6 routes system, it is well thought and it allows developers using my library to expand it and customise it ways that were not possible before, especially regarding nested routes. But unfortunately this little detail is breaking it for me.

@gnapse
Copy link

gnapse commented Aug 31, 2021

How can we know here what background to render

@gnapse Use a string instead of a boolean.

<Route
  path="/img/:id"
  children={
    background === "gallery" ? (
      <>
        <Gallery />
        <Modal />
      </>
    ) ? background === "list" ? (
      <>
        <List />
        <Modal />
      </>
    ) : (
      <ImageView />
    )
  }
/>

Ok, so you're basically telling me to replicate what I've already encoded into routes as an ugly and potentially huge if/else expression in my jsx.

A few questions:

  • What if I have not two but around 10 possible views/routes from which this modal can be fired?
  • What if these background views are also routed using dynamic route parts?
  • What if I have not one, but a couple of modals that use this approach?

It seems you're pretty determined to getting rid of this feature. I really hope you folks reconsider.

@gitcatrat
Copy link

gitcatrat commented Aug 31, 2021

I'm all about simplicity but I can only see pain when looking at my routes. So many different cases..

  • modals like login could be opened from anywhere
  • post modal could only be opened from blog feed
  • user profile modal could be opened from several routes

This would be a nightmare without location prop in my humble opinion and I'm only showing like 20% of my routes here.

<>
  <Header />

  <div className={styles.wrapper}>
    <Switch location={background || location}>
        <Route exact path="/"><Home /></Route>
        <Route path="/login"><Login /></Route>
        <Route path="/user/:id"><UserProfile /></Route>
        <Route path="/post/:id"><CommunityPost /></Route>
        <Route path="/blog/:topic/:sort?"><Blog /></Route>
        <Route path="/blog"><Blog /></Route>

        <Route><NotFound /></Route>
    </Switch>

    <Footer />

    {background &&
      <Modal previousPath={background.pathname}>
        <Switch>
          <Route path="/login"><Login /></Route>
          <Route path="/user/:id"><UserProfile /></Route>
          <Route path="/post/:id"><CommunityPost /></Route>
        </Switch>
      </Modal>
    }
  </div>
</>

@mjackson
Copy link
Member Author

Ok, so you're basically telling me to replicate what I've already encoded into routes as an ugly and potentially huge if/else expression in my jsx.

@gnapse All I did was show you how to do what everyone was saying is impossible to do without the location prop in that one example. If you think the if/else is ugly, feel free to refactor it. I was trying to help you see the diff by changing as little code as possible.

In my opinion, adding the location prop is a very elegant solution.

@fgatti675 I can see why you'd think that for a modal, but I also feel like it's kind of an awkward solution for animation (which is the topic of this issue). For example, maintaining a separate location state just so you can plug it into a <Switch location> as it's animating out is rather awkward. It's much more elegant to use an <AnimatedRoutes> instead and not worry about tracking the state separately.

@gitcatrat Thank you for posting your code. I think this example much more clearly illustrates the need for the location prop than our current modal example from v5 does. I've reopened #7117 so we can keep discussing this use case there.

However, I still think <AnimatedRoutes> and <AnimatedOutlet> are nicer APIs for doing animation specifically than <Switch location> ever was. So unless someone has an objection, I think we'll plan on adding these in v6.

@timdorr
Copy link
Member

timdorr commented Aug 31, 2021

@mjackson
For example, maintaining a separate location state just so you can plug it into a as it's animating out is rather awkward. It's much more elegant to use an instead and not worry about tracking the state separately.

Again, this isn't actually more elegant. You still need to get the location from useLocation and pass location.key into your Transition components. You've just removed one usage of it as a prop:

let location = useLocation();
return <TransitionGroup>
  <CSSTransition key={location.key}>
    <Routes location={location}>

// vs

let location = useLocation();
return <TransitionGroup>
  <CSSTransition key={location.key}>
    <AnimatedRoutes>

And, more importantly, there are now two entirely different sets components/hooks to use in different situations. It becomes unclear if they can be used interchangeably or what their different props imply. Does basename function the same on AnimatedRoutes vs Routes? What happens if I mix up an <AnimatedOutlet> with an <Outlet>?

Maybe it would be helpful to understand the motivations behind this API change. It seems to be to avoid some kind of common footgun that users are running into, although I'm not sure what that is exactly. Can you describe that more or give some other context? You also mentioned v6 being hook-based, but I'm not sure I understand how that factors in to a component API.

I feel like there's some common ground amongst the stakeholders here, we just need to find it.

@timdorr
Copy link
Member

timdorr commented Aug 31, 2021

One idea I just had:

<TransitionGroup>
  <AnimatedRoutes animator={CSSTransition}>

Just cloneElement on the animator, add the key: location.key as a prop, and wrap your children with that. Then there's no need for the userspace location.key stuff. Avoids an error-prone copypasta of a common pattern and wraps it up in an API instead.

@mjackson
Copy link
Member Author

You still need to get the location from useLocation and pass location.key into your Transition components.

@timdorr The <CSSTransition key> can be ANY key, not just a location.key. It just needs to change whenever you want to start a new animation.

We use location.key in all our animation examples since we want to trigger our animations based on URL changes (link clicks, back/forward buttons, etc.). We are a routing library, after all. But if you wanted you could use any other key to kick off your animation. It could just come from a change in some local state. It's not a super compelling example (because we always encourage updating the actual URL when the page changes), but I forked our v5 animation demo to show what this could look like. The example just has a single route that renders at /, but it demonstrates the behavior nonetheless. <AnimatedRoutes> effectively decouples the need for useLocation from animations.

@gnapse
Copy link

gnapse commented Aug 31, 2021

Ok, so you're basically telling me to replicate what I've already encoded into routes as an ugly and potentially huge if/else expression in my jsx.

@gnapse All I did was show you how to do what everyone was saying is impossible to do without the location prop in that one example. If you think the if/else is ugly, feel free to refactor it. I was trying to help you see the diff by changing as little code as possible.

@mjackson, it is not that it is ugly. It's the fact that I'd be replicating what react-router already does really well: mapping a location to something that should be rendered for that location.

Not just that, there are things that I suspect are impossible. Because react-router's algorithm to map locations to elements goes beyond a mere background === 'gallery'. I posed a question to you that's key to this point:

What if these background views are also routed using dynamic route parts?

What if the location that's used to decide whether to render <Gallery /> or <List /> are of the sort of /gallery/:projectId and /list/:projectId, and both <Gallery /> and <List /> expect to read these URL params via the useParams() hook? Following your suggestion, I'd be rendering them inside a route for path /img/:id. How will these two components be able to read the projectId?

Do you have a suggestion on how to solve that in user land without the location prop? Even if it's ugly?

@ryanflorence
Copy link
Member

ryanflorence commented Sep 1, 2021

@timdorr

Can you describe that more or give some other context?

<Routes location> is going to happen, so if you want to animate at the top by passing the location to routes, you can.

Why <Routes location> makes sense

This <Routes location> API makes sense to me because Routes is the thing that:

  1. Knows the route tree so it can match the location against it
  2. Sets the rest of the context that every component/hook in the library needs (including <Outlet>)
  3. Only needs the location to do all of that

Given all of that, adding <Routes location> is right in line with the core React concept of controlled vs. uncontrolled components. Routes can either be uncontrolled (gets location from context) or controlled (gets location from props).

So you can use <Routes location> for animations (though the real use case for this API is rendering routes based on navigation paths instead of just the URL). But let's put <Routes> to the side for a minute.

Why <Outlet location> doesn't make sense

I think the most important animation use case we need to handle is <Outlet/>.

Unless you're animating the entire document, you're going to be animating an <Outlet>, not <Routes>. Animations almost always indicate some relationship between the two routes--they're either siblings or parent/child.

So even though <Routes location> will be implemented, it is actually of little use for animations.

At first we thought maybe do the same thing: <Outlet location/>, but it's not so simple. Outlet's job is just to continue rendering the next slice of the nested route tree.

  • It doesn't do any matching.
  • It doesn't set any context.
  • It has no state

It doesn't make sense to have a "controlled" version with <Outlet location>. There is no state to control!

So what is the actual requirement of animating elements? The ability to freeze the world so the exiting screen doesn't use the new location from context (and accidentally animate out the same screen that's animating in 🤦🏼‍♂️🤣)

Since outlet has no state to control, we can freeze it another way: initializing context into state on the initial render.

function AnimatedOutlet() {
  let [context] = useState(useOutlet());
  return context.outlet;
}

World frozen! That's all we need. We never need updates because the animation abstraction forces remounts anyway (otherwise you can't animate, that's literally the one job of an animation library in React).

Of course, we could add <Outlet location> with a lot of work, more complicated code (now outlet has to become stateful and learn how to match) and strange React semantics (it's like trying to control the state of an <option> instead of using the value on <select>), but the end result would be that this:

<TransitionGroup>
  <CSSTransition key={location.key}>
    <AnimatedOutlet />
  </CSSTransition>
</TransitionGroup>

Looks like this:

<TransitionGroup>
  <CSSTransition key={location.key}>
    <Outlet location={location} />
  </CSSTransition>
</TransitionGroup>

Or so that this:

const transitions = ReactSpring.useTransition(location, {/*...*/})
return (
  <div style={{ display: 'flex' }}>
    {transitions(({ /* ... */ }, location) => (
      <animated.div style={{ /* ... */ }}>
        <AnimatedOutlet />
      </animated.div>
    ))}
  </div>
)

Looks like this:

const transitions = ReactSpring.useTransition(location, {/*...*/})
return (
  <div style={{ display: 'flex' }}>
    {transitions(({ /* ... */ }, location) => (
      <animated.div style={{ /* ... */ }}>
        <Outlet location={location} />
      </animated.div>
    ))}
  </div>
)

I see no reason to complicate the implementation of React Router just so people can pass a prop to Outlet instead of using <AnimatedOutlet/>.

And since we've got AnimatedOutlet, and it takes two lines of code, why not round out the animation API with <AnimatedRoutes/> for consistency?

Of course, more pedantic names would be <StaticRoutes /> and <StaticOutlet />, but "Animated" describes the use case it's made for and makes it more likely people looking for a solution will find it.

I hope this novel helps describe our thinking 😂

@mjackson
Copy link
Member Author

mjackson commented Sep 1, 2021

if react-router wants to gain traction in react-native land

@CooCooCaCha I just remembered that you had said this, and I wanted to let you know that React Native support is not a goal for us in v6. We have supported React Router on React Native for several years now, but haven't seen very much engagement from the React Native community, unfortunately. So we are not planning on shipping v6 for React Native.

@CooCooCaCha
Copy link

if react-router wants to gain traction in react-native land

@CooCooCaCha I just remembered that you had said this, and I wanted to let you know that React Native support is not a goal for us in v6. We have supported React Router on React Native for several years now, but haven't seen very much engagement from the React Native community, unfortunately. So we are not planning on shipping v6 for React Native.

That's quite unfortunate to hear. If I'm being honest, I've been following react-router for awhile and I actually got the impression that you all never wanted to support react-native in the first place. Perhaps the community picked up on that too and went a different route.

Anyways, best of luck.

@wojtekmaj
Copy link

Coming from React DOM world I find out extremely useful to be able to use react-router based components in both web and native version of the app I'm developing. Saves me a ton of time.

Do you think there's anything we could do to help releasing RR6 for RN as well? Including financially.

@MeiKatz
Copy link
Contributor

MeiKatz commented Sep 2, 2021

@ryanflorence I like your solution, but why don't we add a animated prop to <Outlet />? It's not that complicate to add and it would save us two additional components: <AnimatedRoutes /> and <AnimatedOutlet />.

function Outlet({ animated = false }) {
  const currentOutlet = useOutlet();
  const prevOutlet = useState( currentOutlet );

  return (
    animated
      ? prevOutlet
      : currentOutlet
  );
}

@remix-run remix-run locked and limited conversation to collaborators Sep 4, 2021
@chaance chaance closed this as completed Sep 4, 2021

This issue was moved to a discussion.

You can continue the conversation there. Go to discussion →

Labels
None yet
Projects
None yet
Development

No branches or pull requests