Custom Route Component Never Matches on exact path="/"

Viewed 54

This is a bit of a complicated problem, so I'll keep it as brief as possible.

Symptoms

I have a PrivateRoute component built around the react-router-dom Router component. Essentially, the Route is supplied a list of allowedRoles and a boolean value called allBut. If allBut == true, the user can view the route if they possess any user roles not included in allowedRoles. If it's false, they can view it if they have any roles that are included in allowedRoles. If the user is authorized, the route will render its children, else it will render a React.ReactNode supplied through its unauthorizedRedirect prop. It works great, except for one infuriating caveat.

Running the following Jest/React Testing Library test:

  const SUCCESS_MESSAGE = "Success; you're authorized!";
  const LOGIN_MESSAGE = "You weren't logged in, so we redirected you!";
  const RENDER_ON_AUTH = () => <div>{SUCCESS_MESSAGE}</div>;
  const RENDER_ON_LOGIN = () => <div>{LOGIN_MESSAGE}</div>;
  test('PrivateRoute renders its children when user holds a role other than the roles listed in allowedRoles when allBut == true', () => {
    const testContext = cloneDeep(CUSTOM_CONTEXT);
    testContext.devSettings.userOverride.userRoles = [DefaultRoles.Authenticated];
    render(
      <ProvideAuth customContext={testContext}>
        <Router>
          <Switch>
            <PrivateRoute
              exact
              path="/"
              allowedRoles={[DefaultRoles.Anonymous]}
              allBut={true}
              unauthorizedRedirect={<Redirect to="/login" />}
            >
              <RENDER_ON_AUTH />
            </PrivateRoute>
            <Route path="/login">
              <RENDER_ON_LOGIN />
            </Route>
          </Switch>
        </Router>
      </ProvideAuth>
    );
    const hopefullySuccessDiv = screen.queryByText(SUCCESS_MESSAGE);
    expect(hopefullySuccessDiv).toBeInTheDocument();
    const hopefullyNotLoginDiv = screen.queryByText(LOGIN_MESSAGE);
    expect(hopefullyNotLoginDiv).not.toBeInTheDocument();
  });

results in:

expect(received).toBeInTheDocument()

    received value must be an HTMLElement or an SVGElement.
    Received has value: null

      55 |     );
      56 |     const hopefullySuccessDiv = screen.queryByText(SUCCESS_MESSAGE);
    > 57 |     expect(hopefullySuccessDiv).toBeInTheDocument();
         |                                 ^
      58 |     const hopefullyNotLoginDiv = screen.queryByText(LOGIN_MESSAGE);
      59 |     expect(hopefullyNotLoginDiv).not.toBeInTheDocument();
      60 |   });

You're probably thinking at this point, "Well, the PrivateRoute is obviously broken. The authorization isn't working, and it's redirecting to '/login'".

Yeah, maybe, but...

The following test will pass with flying colors (as will any other test that results in an "authorized" status:

  test('PrivateRoute renders its children when user holds a role other than the roles listed in allowedRoles when allBut == true', () => {
    const testContext = cloneDeep(CUSTOM_CONTEXT);
    testContext.devSettings.userOverride.userRoles = [DefaultRoles.Authenticated];
    render(
      <ProvideAuth customContext={testContext}>
        <Router>
          <Switch>
            <Redirect exact from="/" to="/home" />
            <PrivateRoute
              exact
              path="/home"
              allowedRoles={[DefaultRoles.Anonymous]}
              allBut={true}
              unauthorizedRedirect={<Redirect to="/login" />}
            >
              <RENDER_ON_AUTH />
            </PrivateRoute>
            <Route path="/login">
              <RENDER_ON_LOGIN />
            </Route>
          </Switch>
        </Router>
      </ProvideAuth>
    );
    const hopefullySuccessDiv = screen.queryByText(SUCCESS_MESSAGE);
    expect(hopefullySuccessDiv).toBeInTheDocument();
    const hopefullyNotLoginDiv = screen.queryByText(LOGIN_MESSAGE);
    expect(hopefullyNotLoginDiv).not.toBeInTheDocument();
  });

See the difference?

There's a Redirect from/to. The redirect matches on "/" and redirects to "/home", at which point the PrivateRoute catches on and performs its logic. I've tested this every which way -- PrivateRoute works in every situation EXCEPT matching on exact path="/".

Please, help. I'm pulling my hair out.

Here are some more implementation details:

// src/components/routes/PrivateRoute.tsx
import React from 'react';
import { Route } from 'react-router-dom';
import { useAuth, authorize } from '../../auth/auth';
import { PrivateRouteComponentProps } from './privateRouteTypes';

/**
 * Renders its children if the user is in one of the allowed roles,
 * else renders the unauthorizedRedirect ReactNode.
 */
const PrivateRoute = ({
  allowedRoles,
  unauthorizedRedirect,
  allBut,
  children,
  ...rest
}: PrivateRouteComponentProps & { unauthorizedRedirect: React.ReactNode }) => {
  const auth = useAuth();
  const isAuthorized = authorize(allowedRoles, auth.user, allBut);
  return <Route {...rest} render={() => (isAuthorized ? children : unauthorizedRedirect)} />;
};

export default PrivateRoute;
// src/components/routes/privateRouteProps.ts
import { RouteComponentProps } from 'react-router-dom';

export interface PrivateComponentProps {
  allowedRoles: Roles;
  allBut?: boolean;
  children: React.ReactNode;
}

export type PrivateRouteComponentProps = {
  [key: string]: any;
} & PrivateComponentProps &
  Partial<RouteComponentProps>;

export type PrivateRouteComponent = (props: PrivateRouteComponentProps) => JSX.Element;
0 Answers
Related