Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions packages/docs/src/pages/LearnLoadersPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,9 @@ function UserDetail({ data }: { data: Promise<User> }) {
<h3>Caching by Navigation Entry</h3>
<p>
Loader results are cached using the <strong>navigation entry ID</strong> from the
Navigation API. Each time you navigate to a new URL, the browser creates a new navigation
entry with a unique ID. The Router uses this ID as the cache key, so:
Navigation API, combined with the identity of the <strong>loader function</strong> and the
matched <strong>params</strong>. Each time you navigate to a new URL, the browser creates
a new navigation entry with a unique ID, so:
</p>
<ul>
<li>
Expand All @@ -91,6 +92,16 @@ function UserDetail({ data }: { data: Promise<User> }) {
This design ensures that loaders run exactly once per navigation while preventing
unnecessary re-fetches during React re-renders.
</p>

<h4>Changing the Routes Prop</h4>
<p>
Because the loader function is part of the cache key, the <code>routes</code> prop can
change dynamically &mdash; for example when routes depend on feature flags or user
permissions. When a route with a <strong>different loader</strong> matches the current URL
after a routes change, that loader executes fresh instead of being served another
loader&rsquo;s cached data. Routes that keep the same loader function keep their cached
results, even if the route definition objects themselves are recreated.
</p>
</section>

<section>
Expand Down
185 changes: 185 additions & 0 deletions packages/router/src/__tests__/dynamicRoutes.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { Router } from "../Router/index.js";
import { Outlet } from "../Outlet.js";
import { route } from "../route.js";
import { setupNavigationMock, cleanupNavigationMock } from "./setup.js";
import { clearLoaderCache } from "../core/loaderCache.js";

describe("Dynamic routes prop", () => {
beforeEach(() => {
setupNavigationMock("http://localhost/");
clearLoaderCache();
});

afterEach(() => {
cleanupNavigationMock();
vi.restoreAllMocks();
});

function Page({ data }: { data: { label: string } }) {
return <div>Label: {data.label}</div>;
}

it("re-executes loaders when the routes prop changes on the same entry", () => {
const loaderA = vi.fn(() => ({ label: "variant A" }));
const loaderB = vi.fn(() => ({ label: "variant B" }));

const routesA = [route({ path: "/", component: Page, loader: loaderA })];
const routesB = [route({ path: "/", component: Page, loader: loaderB })];

const { rerender } = render(<Router routes={routesA} />);
expect(screen.getByText("Label: variant A")).toBeInTheDocument();

// Same URL, same navigation entry — only the routes prop changes,
// as with feature flags or permission-dependent routes.
rerender(<Router routes={routesB} />);
expect(screen.getByText("Label: variant B")).toBeInTheDocument();
expect(loaderB).toHaveBeenCalledTimes(1);
});

it("serves cached results again when switching back to previous routes", () => {
const loaderA = vi.fn(() => ({ label: "variant A" }));
const loaderB = vi.fn(() => ({ label: "variant B" }));

const routesA = [route({ path: "/", component: Page, loader: loaderA })];
const routesB = [route({ path: "/", component: Page, loader: loaderB })];

const { rerender } = render(<Router routes={routesA} />);
rerender(<Router routes={routesB} />);
rerender(<Router routes={routesA} />);

expect(screen.getByText("Label: variant A")).toBeInTheDocument();
// Both variants' results stay cached for this entry, so flipping the
// flag back does not refetch.
expect(loaderA).toHaveBeenCalledTimes(1);
expect(loaderB).toHaveBeenCalledTimes(1);
});

it("does not re-execute loaders when the same routes array is re-rendered", () => {
const loader = vi.fn(() => ({ label: "stable" }));
const routes = [route({ path: "/", component: Page, loader })];

const { rerender } = render(<Router routes={routes} />);
rerender(<Router routes={routes} />);
rerender(<Router routes={routes} />);

expect(screen.getByText("Label: stable")).toBeInTheDocument();
expect(loader).toHaveBeenCalledTimes(1);
});

it("does not re-execute loaders when route objects are recreated with the same loader", () => {
const loader = vi.fn(() => ({ label: "stable loader" }));
const makeRoutes = () => [route({ path: "/", component: Page, loader })];

const { rerender } = render(<Router routes={makeRoutes()} />);
// Route objects are recreated, but they reference the same loader
// function, so the cached result is reused.
rerender(<Router routes={makeRoutes()} />);

expect(screen.getByText("Label: stable loader")).toBeInTheDocument();
expect(loader).toHaveBeenCalledTimes(1);
});

it("re-executes a shared loader when a routes change gives it different params", () => {
cleanupNavigationMock();
setupNavigationMock("http://localhost/home");
clearLoaderCache();

const loader = vi.fn(({ params }: { params: Record<string, string> }) => ({
label: Object.entries(params)
.map(([k, v]) => `${k}=${v}`)
.join(","),
}));

const routesA = [route({ path: "/:page", component: Page, loader })];
const routesB = [route({ path: "/:section", component: Page, loader })];

const { rerender } = render(<Router routes={routesA} />);
expect(screen.getByText("Label: page=home")).toBeInTheDocument();

// Same loader function, but the new pattern extracts different params,
// so the cached result must not be served.
rerender(<Router routes={routesB} />);
expect(screen.getByText("Label: section=home")).toBeInTheDocument();
expect(loader).toHaveBeenCalledTimes(2);
});

it("does not serve one Router's loader data to another Router on the same page", () => {
const loaderA = vi.fn(() => ({ label: "router A" }));
const loaderB = vi.fn(() => ({ label: "router B" }));

const routesA = [route({ path: "/", component: Page, loader: loaderA })];
const routesB = [route({ path: "/", component: Page, loader: loaderB })];

// Both Routers observe the same navigation entry, so without route
// identity in the cache key their loader results would collide.
render(
<>
<Router routes={routesA} />
<Router routes={routesB} />
</>,
);

expect(screen.getByText("Label: router A")).toBeInTheDocument();
expect(screen.getByText("Label: router B")).toBeInTheDocument();
expect(loaderA).toHaveBeenCalledTimes(1);
expect(loaderB).toHaveBeenCalledTimes(1);
});

it("keeps cached results of route objects shared between route trees", () => {
const parentLoaderA = vi.fn(() => ({ label: "layout A" }));
const parentLoaderB = vi.fn(() => ({ label: "layout B" }));
const childLoader = vi.fn(() => ({ label: "dashboard" }));

cleanupNavigationMock();
setupNavigationMock("http://localhost/dashboard");
clearLoaderCache();

function Layout({ data }: { data: { label: string } }) {
return (
<div>
<div>Layout: {data.label}</div>
<Outlet />
</div>
);
}

// The same child route object is shared by both route trees.
const child = route({
path: "dashboard",
component: Page,
loader: childLoader,
});
const routesA = [
route({
path: "/",
component: Layout,
loader: parentLoaderA,
children: [child],
}),
];
const routesB = [
route({
path: "/",
component: Layout,
loader: parentLoaderB,
children: [child],
}),
];

const { rerender } = render(<Router routes={routesA} />);
expect(screen.getByText("Layout: layout A")).toBeInTheDocument();
expect(screen.getByText("Label: dashboard")).toBeInTheDocument();

rerender(<Router routes={routesB} />);
expect(screen.getByText("Layout: layout B")).toBeInTheDocument();
expect(screen.getByText("Label: dashboard")).toBeInTheDocument();

// Only the parent route changed identity; the shared child route keeps
// its cached result and does not re-execute.
expect(parentLoaderA).toHaveBeenCalledTimes(1);
expect(parentLoaderB).toHaveBeenCalledTimes(1);
expect(childLoader).toHaveBeenCalledTimes(1);
});
});
43 changes: 41 additions & 2 deletions packages/router/src/core/loaderCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,49 @@ export class LoaderError {

/**
* Cache for loader results.
* Key format: `${entryId}:${matchIndex}`
* Key format: `${entryId}:${matchIndex}:${loaderId}:${paramsKey}`
*/
const loaderCache = new Map<string, unknown>();

/**
* Identity of each loader function, issued lazily.
*
* Included in cache keys so that a cached result is only ever served for the
* loader that produced it. Without it, swapping the `routes` prop (feature
* flags, permission-dependent routes) or rendering two `<Router>`s would let
* a different route matching at the same index pick up another route's
* cached data.
*
* The loader function is used as the identity — rather than the route
* definition object — because a cached result is fully determined by the
* loader and its args. Route definitions recreated across renders keep their
* cached results as long as they reference the same loader function.
*/
type LoaderFunction = NonNullable<InternalRouteDefinition["loader"]>;

const loaderIds = new WeakMap<LoaderFunction, number>();
let nextLoaderId = 0;

function getLoaderId(loader: LoaderFunction): number {
let id = loaderIds.get(loader);
if (id === undefined) {
id = nextLoaderId++;
loaderIds.set(loader, id);
}
return id;
}

/**
* Serialize matched params into a stable cache key component.
*
* Params are part of the loader's input: the same loader function reached
* via a different path pattern (e.g. `/:page` vs `/:section`) receives
* different params for the same URL and must not share a cached result.
*/
function getParamsKey(params: Record<string, string>): string {
return JSON.stringify(Object.entries(params).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
}

/**
* Get or create a loader result from cache.
* If the result is not cached, executes the loader and caches the result.
Expand All @@ -32,7 +71,7 @@ function getOrCreateLoaderResult(
return undefined;
}

const cacheKey = `${entryId}:${matchIndex}`;
const cacheKey = `${entryId}:${matchIndex}:${getLoaderId(route.loader)}:${getParamsKey(args.params)}`;

if (!cache.has(cacheKey)) {
try {
Expand Down