Skip to content

Commit 8e53b80

Browse files
committed
fix: use entry.id as cache key
1 parent 2fed667 commit 8e53b80

File tree

5 files changed

+76
-19
lines changed

5 files changed

+76
-19
lines changed

packages/router/src/Router.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,17 +49,18 @@ export function Router({ routes, children }: RouterProps): ReactNode {
4949

5050
// Match current URL against routes and execute loaders
5151
const currentUrl = currentEntry?.url;
52+
const currentEntryId = currentEntry?.id;
5253
const matchedRoutesWithData = useMemo(() => {
53-
if (!currentUrl) return null;
54+
if (!currentUrl || !currentEntryId) return null;
5455
const url = new URL(currentUrl);
5556
const matched = matchRoutes(routes, url.pathname);
5657
if (!matched) return null;
5758

58-
// Execute loaders (results are cached by URL pathname)
59+
// Execute loaders (results are cached by navigation entry id)
5960
const request = createLoaderRequest(url);
6061
const signal = getIdleAbortSignal();
61-
return executeLoaders(matched, url.pathname, request, signal);
62-
}, [currentUrl, routes]);
62+
return executeLoaders(matched, currentEntryId, request, signal);
63+
}, [currentUrl, currentEntryId, routes]);
6364

6465
const routerContextValue = useMemo(
6566
() => ({ currentEntry, navigate }),

packages/router/src/__tests__/loader.test.tsx

Lines changed: 45 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2-
import { render, screen, act, waitFor } from "@testing-library/react";
2+
import { render, screen, act } from "@testing-library/react";
33
import { Router } from "../Router.js";
44
import { Outlet } from "../Outlet.js";
55
import { route } from "../route.js";
@@ -298,7 +298,7 @@ describe("Data Loader", () => {
298298
expect(screen.getByText(/Count:/).textContent).toBe(firstCount);
299299
});
300300

301-
it("caches results per URL pathname", () => {
301+
it("caches results per navigation entry id", () => {
302302
mockNavigation = setupNavigationMock("http://localhost/page1");
303303

304304
const loaderSpy = vi.fn(({ params }: LoaderArgs) => ({
@@ -321,22 +321,60 @@ describe("Data Loader", () => {
321321
expect(loaderSpy).toHaveBeenCalledTimes(1);
322322
expect(screen.getByText("Page: page1")).toBeInTheDocument();
323323

324-
// Navigate to a different page
324+
// Navigate to a different page (creates new entry at index 1)
325325
act(() => {
326326
mockNavigation.__simulateNavigation("http://localhost/page2");
327327
});
328328

329-
// Loader should be called again for the new URL
329+
// Loader should be called again for the new entry
330330
expect(loaderSpy).toHaveBeenCalledTimes(2);
331331
expect(screen.getByText("Page: page2")).toBeInTheDocument();
332332

333-
// Navigate back to the first page
333+
// Traverse back to the first entry (back button behavior)
334334
act(() => {
335-
mockNavigation.__simulateNavigation("http://localhost/page1");
335+
mockNavigation.__simulateTraversal(0);
336336
});
337337

338-
// Loader should NOT be called again (cache hit)
338+
// Loader should NOT be called again (cache hit - same entry id)
339+
expect(loaderSpy).toHaveBeenCalledTimes(2);
340+
expect(screen.getByText("Page: page1")).toBeInTheDocument();
341+
});
342+
343+
it("calls loader again for new navigation to same URL", () => {
344+
mockNavigation = setupNavigationMock("http://localhost/page1");
345+
346+
const loaderSpy = vi.fn(({ params }: LoaderArgs) => ({
347+
page: params.page,
348+
}));
349+
350+
function Page({ data }: { data: { page: string } }) {
351+
return <div>Page: {data.page}</div>;
352+
}
353+
354+
const routes = [
355+
route({
356+
path: "/:page",
357+
component: Page,
358+
loader: loaderSpy,
359+
}),
360+
];
361+
362+
render(<Router routes={routes} />);
363+
expect(loaderSpy).toHaveBeenCalledTimes(1);
364+
365+
// Navigate to a different page
366+
act(() => {
367+
mockNavigation.__simulateNavigation("http://localhost/page2");
368+
});
339369
expect(loaderSpy).toHaveBeenCalledTimes(2);
370+
371+
// Navigate forward to the same URL as the first page (creates new entry)
372+
act(() => {
373+
mockNavigation.__simulateNavigation("http://localhost/page1");
374+
});
375+
376+
// Loader SHOULD be called again (new entry, different id)
377+
expect(loaderSpy).toHaveBeenCalledTimes(3);
340378
expect(screen.getByText("Page: page1")).toBeInTheDocument();
341379
});
342380
});

packages/router/src/__tests__/setup.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,17 @@ export function createMockNavigation(initialUrl = "http://localhost/") {
9393
mockNavigation.navigate(url, { state });
9494
},
9595

96+
// Test helper to simulate traverse navigation (back/forward)
97+
// This reuses an existing entry instead of creating a new one
98+
__simulateTraversal(entryIndex: number) {
99+
if (entryIndex < 0 || entryIndex >= entries.length) {
100+
throw new Error(`Invalid entry index: ${entryIndex}`);
101+
}
102+
currentEntry = entries[entryIndex];
103+
mockNavigation.currentEntry = currentEntry;
104+
dispatchEvent("currententrychange", new Event("currententrychange"));
105+
},
106+
96107
// Test helper to get listeners
97108
__getListeners(type: string) {
98109
return listeners.get(type);

packages/router/src/core/loaderCache.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import type {
77

88
/**
99
* Cache for loader results.
10-
* Key format: `${entryKey}:${routePath}`
10+
* Key format: `${entryId}:${routePath}`
1111
*/
1212
const loaderCache = new Map<string, unknown>();
1313

@@ -16,7 +16,7 @@ const loaderCache = new Map<string, unknown>();
1616
* If the result is not cached, executes the loader and caches the result.
1717
*/
1818
function getOrCreateLoaderResult(
19-
entryKey: string,
19+
entryId: string,
2020
// eslint-disable-next-line @typescript-eslint/no-explicit-any
2121
route: RouteDefinition<any>,
2222
args: LoaderArgs,
@@ -25,7 +25,7 @@ function getOrCreateLoaderResult(
2525
return undefined;
2626
}
2727

28-
const cacheKey = `${entryKey}:${route.path}`;
28+
const cacheKey = `${entryId}:${route.path}`;
2929

3030
if (!loaderCache.has(cacheKey)) {
3131
loaderCache.set(cacheKey, route.loader(args));
@@ -45,18 +45,18 @@ export function createLoaderRequest(url: URL): Request {
4545

4646
/**
4747
* Execute loaders for matched routes and return routes with data.
48-
* Results are cached by navigation entry key to prevent duplicate execution.
48+
* Results are cached by navigation entry id to prevent duplicate execution.
4949
*/
5050
export function executeLoaders(
5151
matchedRoutes: MatchedRoute[],
52-
entryKey: string,
52+
entryId: string,
5353
request: Request,
5454
signal: AbortSignal,
5555
): MatchedRouteWithData[] {
5656
return matchedRoutes.map((match) => {
5757
const { route, params } = match;
5858
const args: LoaderArgs = { params, request, signal };
59-
const data = getOrCreateLoaderResult(entryKey, route, args);
59+
const data = getOrCreateLoaderResult(entryId, route, args);
6060

6161
return { ...match, data };
6262
});

packages/router/src/core/navigation.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,8 +97,15 @@ export function setupNavigationInterception(
9797
const request = createLoaderRequest(url);
9898

9999
// Execute loaders immediately (before React re-renders)
100-
// Results are cached, so React render will hit the cache
101-
executeLoaders(matched, url.pathname, request, event.signal);
100+
// Results are cached by entry id, so React render will hit the cache
101+
// Note: destination.id is only available for traverse navigations (back/forward)
102+
// For push/replace navigations, destination.id is empty and cache won't be shared
103+
executeLoaders(
104+
matched,
105+
event.destination.id ?? "",
106+
request,
107+
event.signal,
108+
);
102109

103110
event.intercept({
104111
handler: async () => {

0 commit comments

Comments
 (0)