Skip to content
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

SPA Mode + <NavLink to="/" end> rendered in root.tsx Layout component will always start as "active" #13010

Open
tjallingt opened this issue Feb 12, 2025 · 3 comments
Labels

Comments

@tjallingt
Copy link

tjallingt commented Feb 12, 2025

I'm using React Router as a...

framework

Reproduction

https://stackblitz.com/edit/github-7pxkksy3?file=app%2Froot.tsx

Project setup

root.tsx

import {
  Links,
  Meta,
  Outlet,
  Scripts,
  ScrollRestoration,
  NavLink,
} from 'react-router';

import './app.css';

export function Layout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <head>
        <meta charSet="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <Meta />
        <Links />
      </head>
      <body>
        <nav>
          <ul>
            <li>
              <NavLink to="/" end>
                Home
              </NavLink>
            </li>
            <li>
              <NavLink to="/other">Other</NavLink>
            </li>
          </ul>
        </nav>

        {children}
        <ScrollRestoration />
        <Scripts />
      </body>
    </html>
  );
}

export default function App() {
  return <Outlet />;
}

routes.ts

import { type RouteConfig, index, route } from '@react-router/dev/routes';

export default [
  index('routes/home.tsx'),
  route('other', 'routes/other.tsx'),
] satisfies RouteConfig;

app.css

nav a.active {
  background-color: #e76829;
}

routes/home.tsx

export default function Home() {
  return (
    <div className="text-center p-4">
      <h1 className="text-2xl">Hello, Home</h1>
    </div>
  );
}

routes/other.tsx

export default function Other() {
  return (
    <div className="text-center p-4">
      <h1 className="text-2xl">Hello, Other</h1>
    </div>
  );
}

react-router.config.ts

import type { Config } from "@react-router/dev/config";

export default {
  ssr: true,
} satisfies Config;

Steps to reproduce

  1. run react-router build in the terminal
  2. serve build/client with a webserver
  1. open localhost:8080, NavLink for "home" should be active (orange background)
  2. click on NavLink for "other", this navigates and makes it active
  3. refresh the page now the NavLink for "home" is active again even though we're looking at the "other" page
  • this also happens when just skipping step 3 and 4 and navigating to localhost:8080/other directly

System Info

System:
    OS: Windows 11 10.0.22631
    CPU: (8) x64 11th Gen Intel(R) Core(TM) i5-1145G7 @ 2.60GHz
    Memory: 1.17 GB / 15.39 GB
  Binaries:
    Node: 22.13.1 - ~\scoop\apps\nvm\current\nodejs\nodejs\node.EXE
    npm: 10.9.2 - ~\scoop\apps\nvm\current\nodejs\nodejs\npm.CMD
  Browsers:
    Edge: Chromium (131.0.2903.146)
    Internet Explorer: 11.0.22621.3527
  npmPackages:
    @react-router/dev: ^7.1.3 => 7.1.5
    @react-router/node: ^7.1.3 => 7.1.5
    react-router: ^7.1.3 => 7.1.5
    vite: ^6.0.7 => 6.1.0

Used Package Manager

npm

Expected Behavior

When serving the build/client directory with a server and directly visiting localhost:8080/other the NavLink for "other" should be active.

Actual Behavior

When directly visiting localhost:8080/other the NavLink for "home" is active.

Note that if you have multiple NavLink "home" stays active until you visit the home route and navigate away again. Navigating to any other route while "home" is active will show two active NavLinks.

Workaround

Moving the whole <nav> into the routes/home.tsx and routes/other.tsx directly seems to fix the issue, when reloading the page the correct NavLink becomes active.

Potential cause

When looking at build/client/index.html we can see that the NavLinks have been pre-rendered (as described in the docs https://reactrouter.com/how-to/spa#important-note) and the "home" NavLink has been pre-rendered with class="active".
Somehow after hydration this active class should switch to the correct NavLink but it doesn't. Interestingly there are no hydration warnings from React in the console.

Potential solutions

This could very well be me "misusing" NavLinks (that they are not supposed to be rendered in <Layout />), in which case I hope I can be pointed to the right docs or that I can help update the documentation to make this behaviour clearer.
Otherwise I see two strategies:

  • NavLinks should pre-render without their active class
  • After hydration the NavLinks should rerender to show the active class
@tjallingt tjallingt added the bug label Feb 12, 2025
@timdorr
Copy link
Member

timdorr commented Feb 12, 2025

As you commented, this is essentially a "misuse" of NavLink within the root module. And you are also correct that we have poor documentation here. We have #13000 open to track adding that into our docs.

I'm not sure what a good fix is here, as the root module is being rendered down to plain HTML and will be static upon first load. We choose the root URL to render against, so that is why it is active despite the different URL in the browser. Maybe a warning of some sort could be added? I'm not sure how possible that might be.

@brophdawg11
Copy link
Contributor

As mentioned this would be expected on first load, but I'm unsure why it's not flipping over on hydration. NavLink runs again on hydration and properly detects that / is no longer "active" but it doesn't seem like that render flushes to the DOM...

Duplicating the layout <html>...</html> shell in your default export and a HydrateFallback fixes the issue, as does manually wrapping a layout and skipping the built in Layout component usage:

export default function App() {
  return <MyLayout><Outlet /></MyLayout>;
}

export function HydrateFallback() {
  return <MyLayout><p>Loading...</p></MyLayout>;
}

So that tells me it has something to do with the way Layout is wrapped around the root UI components internally. I don't have time to dig in right now but will try to take a look at some point.

@tjallingt
Copy link
Author

tjallingt commented Feb 13, 2025

Duplicating the layout ... shell in your default export and a HydrateFallback fixes the issue

Yea after making this issue I added a HydrateFallback since the console.log encouraged me to do so. After moving my <NavBar /> component from the root.tsx Layout to every page and the root.tsx HydrateFallback I get behaviour that is, almost, what I want:
The page loads with "home" active and then rerenders making "other" active.

This way I get the benefits from prerendering since my initial page contains the nav bar (unfortunately with the "home" NavLink active but ok) and after hydration the page updates to show the correct NavLink as active.

I think it would make sense if during prerendering the NavLinks would not render className="active". Conceptually you're just prerendering the shell/root, which is not really at any particular URL. Ofcourse this is different when doing SSR where the URL is known and the NavLink should render as active.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

3 participants