Ionic renders old routes that returned a <Redirect /> when state changes

I’m hoping I’m doing something silly. I’ve made a very basic reproduction of the issue that only has a simple auth page, a login function, and two protected pages - GitHub - Toddish/ionic-routing-issue.

I’m protecting pages like this:

<Route path="/feed" exact>
    {user ? <DummyPage page="feed" /> : <Redirect to="/auth" />}
</Route>

The user is stored in context.

When you log in and go between the protected pages, all is fine. When you log out and log back in again, all the protected pages re-render and animate in.

Looking at the inspector, ionic is removing the pages from the dom when logging out (I assume because that route now returns a redirect), and when you log back in it re-renders them with their IonPage component, animating them in.

This sort of route guarding seems to be the way to go, with a lot of people recommending it. Am I doing something wrong? Does it not work in ionic 8?

Here’s a video of the issue:

ionic-routing

I didn’t examine your code in detail, but in src/App.tsx, you have this:

  const { user } = useUserContext();
  return (
    <IonApp>
      <IonReactRouter>
        <IonRouterOutlet>
          <Route path="/auth" exact>
            <AuthPage />
          </Route>
          <Route path="/feed" exact>
            {user ? <DummyPage page="feed" /> : <Redirect to="/auth" />}
          </Route>
          <Route path="/profile" exact>
            {user ? <DummyPage page="profile" /> : <Redirect to="/auth" />}
          </Route>
          <Route exact path="/">
            <Redirect to="/auth" />
          </Route>
        </IonRouterOutlet>
      </IonReactRouter>
    </IonApp>
  );
};

This isn’t going to work because in React, when a context changes, it re-renders everything that depends on the context. So in this case, every time you change context, you are re-rendering the entire app, from <IonApp> on down. You should never do this.

Instead, create a component like MyAuthorizedRoute:

      <Route path="/feed" exact>
        <AuthorizedRoute routeToShowAuth={<DummyPage page="feed" />} />
      </Route>

This <AuthorizedRoute> component should check the context; that way, when the context is changed, only the <AuthorizedRoute> component re-renders, not the whole app.

For more info, see the react docs:

  • React automatically re-renders all the children that use a particular context starting from the provider that receives a different value. The previous and the next values are compared with the Object.is comparison. Skipping re-renders with memo does not prevent the children receiving fresh context values.

Hey,

Cheers for the that. The code was just a simple, small reproduction of the issue.

I’ve moved the context lookup to a protected route to avoid any confusion, and to show the issue still persists.

Just to add, I’m pretty convinced this is just how ionic works. If you render an IonPage component the page transitions in.

I’m more confused as to how people seem to have this working, as it’s what a lot of people suggest to do. Or if there is a bug, and the ion-page element should have the not visible class as it’s in the history.

I can get round it by handling the auth in each page, conditionally showing an error page or the content based on the user. This keeps the ion page in the dom, and everything works as intended.

It’s just not as clean as the redirect.

It looks like you didn’t render the context provider anywhere, so the context isn’t going to work. useContext() hook needs a provider or you always get the default result.

The context provider was being used in main.tsx. I’ve moved it to App.tsx so it’s clearer.

If you check out the code, you can see everything is working. It’s only the re-rendering of pages in the history that I’m having issues with.

Basically, rendering protected page A → going to protected page B → logging out and going to Auth page → logging in → seeing page A and page B being re-rendered and animating in, when they should still be hidden (because, I assume, a fresh IonPage component is mounted).

If you’ve implemented an auth guard without this issue, I’d love to know how/see the code.

I would generally try to place the context provider as close as possible to the components that need it; for example, the context provider should not be above <IonApp> if <IonApp> does not need it.

When you see pages re-render unexpectedly, it may be because a parent component is getting re-rendered when it shouldn’t be, or it could be that some background server refresh caused an unexpected re-render (not the case here). You can try minimizing such re-renders with useMemo(), but it’s better to prevent them in the first place. <Suspense> can help with this but I’m not sure whether it’s relevant in this specific case.

I do have a working auth guard but I’m using react-query because my user object is derived from the server state, so it’s not a straightforward comparison to your app that is using context.

Here’s an example of the router. userObject is pulled in from a useQuery() hook (react-query). This makes a request to a server, so everything is handled async with suspense. localStoragePreferences is an object generated from reading values with the Capacitor storage plugin. In this example, it’s only used to set a redirect destination.

      <IonReactRouter>
          <Suspense fallback={fallbackAppLoading}>
            <Switch>
              <AppTabBar>
                <IonRouterOutlet>
                  <Route
                    exact
                    path={routes.FormResetPass}
                    render={() =>
                      handleAnonRoute(
                        userObject,
                        <PageResetPassword />,
                        localStoragePreferences,
                      )
                    }
                  />

And the auth guard looks like this (I use several, depending on the account type, but this is the simplest one):

const handleAnonRoute = (
  userObject: User,
  component: React.JSX.Element,
  localStoragePreferences: LocalStoragePreferences,
) => {
  if (userObject.isLoggedIn) {
    return (
      <RedirectToStartPage localStoragePreferences={localStoragePreferences} />
    );
  }
  // eslint-disable-next-line react/jsx-no-useless-fragment
  return <>{component}</>;
};

This is the simplified redirect component:

const RedirectToStartPage: React.FC<MyProps> = ({
  localStoragePreferences,
}: MyProps) => {
  const userObject = useUserObject();
  const routes = useRouteContext();

  //  First time to launch the app.
  if (!localStoragePreferences.onboarding.hasLaunchedAppBefore) {
    return <Redirect to={routes.FirstRunRouter} />;
  }

  // Not logged in yet.
  if (!userObject.isLoggedIn) {
    return (
      <RedirectByQueryStringOrDefault
        localStoragePreferences={localStoragePreferences}
        userObject={userObject}
        defaultRouteDirect={routes.Login}
      />
    );
  }

  // Subscription has expired.
  return <Redirect to={routes.Store} />;
};

This works as expected; I can visit a bunch of pages, log out, and I don’t see any page re-renders. I also have an auth guard for whether a user has paid for a subscription, and I can add the subscription, update the user object, and then the new routes become accessible without re-rendering; once the subscription expires, the user loses access to those routes but no re-renders occur. So it is definitely possible to implement.

Hey @ptmkenny, thanks for such a detailed reply!

I’ve looked through your code, and it looks we’re basically doing the same thing: returning a redirect if auth is false, otherwise returning the children (the page itself).

When you see pages re-render unexpectedly, it may be because a parent component is getting re-rendered when it shouldn’t be

I thought this might have hit the nail on the head, using context inside the protected routes is re-rendering them, instead of just ionic re-rendering the page without the user.

However, I switched to a simple useUser hook to test, which just loads from localstorage, and the same issue persists.

const useUser = () => {
  const user = localStorage.getItem("user");
  const logIn = (user: string) => {
    localStorage.setItem("user", user);
  };
  const logOut = () => {
    localStorage.removeItem("user");
  };

  return { user, logIn, logOut };
};

Do you happen to know what your DOM looks like when logging in and out?

This is what mine looks like when using context and protected routes (and also the useUser hook):

dom

And this is what it looks like when using a normal Route and no auth guard (so never returning a Redirect).

route

You can see in the first example the two protected pages being removed from the dom, as that route is now returning a Redirect. Then, when I log back in, you can see them re-appearing (and this is when they animate in).

In the second example, they’re never removed from the dom, and so they never animate back in and everything works as intended.

I then thought it was because I was passing in ...rest to my route, and the location was causing a re-render. So I removed that and only passed in path and exact.

I tried putting it inside a function like you did, inside the render method, but the same thing.

Either I don’t pass state, and ionic uses the cached version of the page (so I can log out, and still go to the feed page despite the returned redirect, because the route no longer gets called it seems because ionic has matched a component in the tree), or I use state and ionic re-renders all components in the tree when it gets changed, and I get the animating re-renders.

What does your log out look like? Does it update any state at the root of your app?

With the useUser() hook, you might try wrapping the logIn() and logOut() functions in useCallback(); not using useCallback() can also generate rerenders in some circumstances. All these hooks inside hooks in React really annoyed me, which is why I rebuilt my app with react-query.

Here’s my logout function:

const postLogout = async (
  platform: Platform,
  localStoragePreferences: LocalStoragePreferences,
  noLogoutUrlCall?: boolean,
): Promise<boolean> => {
  if (noLogoutUrlCall === true) {
    return Promise.resolve(true);
  }

  await removeCookiesOnMobile(platform);

  // Do not await this because awaiting will not log out properly on the PWA.
  return invalidateDrupalSession(localStoragePreferences)
    .then(() => Promise.resolve(true))
    .catch((error: unknown) => Promise.reject(catchDrupalError(error)));
};

When the session is invalidated, a custom hook reloads the generic user object with react-query.

In my case the pages stay in the DOM as expected.

It occurs to me that one way to work around this is to remove all the logged in history when logging out using window.location.reload(); it’s not elegant, but reloading the window will reset the history and so pages definfitely won’t reappear.

Ah cheers, I might have a further look into react-query.

I originally had a .reload() as a dirty quick fix, but it felt far too hacky. I wish there was a way clearing the stored component history in ionic, that’d be ideal.

For now, I’ve wrapped all my authed pages in a ProtectedPage component, which returns different content if the user doesn’t exist. It means the pages are always in the dom, and the user should rarely see it anyway.

Thanks for all the detailed responses, they’ve really helped me think about how the app is built at least!

You’re welcome. One day when we get React Router 6 support it might become easier to keep state in the URL/query string, and that make things a lot easier since reloading would immediately give you back all the changes the user had made to the page.

I was facing a similar issue, where upon signing in, I would get unwanted animations because of offscreen renders. What solved the issue in the end for me was using history.replace('/protected-page') instead of history.push after a successful sign in. I’m still not entirely sure how removing the redirected sign in page from the history stacks solves the issue though.