From 003ccb9389c9c49e1a70c08ca02a362c27d73b24 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Mar 2026 00:21:23 +0000 Subject: [PATCH 1/5] feat: expose entryId and entryKey from Navigation API in useLocation Add NavigationHistoryEntry.id and NavigationHistoryEntry.key to the Location type returned by useLocation(). These are exposed as entryId and entryKey respectively, and are null when the Navigation API is unavailable (e.g., static fallback mode). https://claude.ai/code/session_014uqruYL8PktVwDVrSA6HJQ --- packages/router/src/Router/index.tsx | 6 +++++ packages/router/src/__tests__/hooks.test.tsx | 22 +++++++++++++++++++ packages/router/src/context/RouterContext.ts | 4 ++++ .../router/src/core/NavigationAPIAdapter.ts | 2 ++ packages/router/src/core/RouterAdapter.ts | 4 ++++ packages/router/src/core/StaticAdapter.ts | 2 ++ packages/router/src/hooks/useLocation.ts | 6 +++-- packages/router/src/types.ts | 4 ++++ 8 files changed, 48 insertions(+), 2 deletions(-) diff --git a/packages/router/src/Router/index.tsx b/packages/router/src/Router/index.tsx index 9511667..fa45ee3 100644 --- a/packages/router/src/Router/index.tsx +++ b/packages/router/src/Router/index.tsx @@ -263,11 +263,15 @@ export function Router({ const locationState = locationEntry?.state; const locationInfo = locationEntry?.info; + const entryId = locationEntry?.entryId ?? null; + const entryKey = locationEntry?.entryKey ?? null; const routerContextValue: RouterContextValue = useMemo( () => ({ locationState, locationInfo, url: urlObject, + entryId, + entryKey, isPending, navigateAsync, updateCurrentEntryState, @@ -276,6 +280,8 @@ export function Router({ locationState, locationInfo, urlObject, + entryId, + entryKey, isPending, navigateAsync, updateCurrentEntryState, diff --git a/packages/router/src/__tests__/hooks.test.tsx b/packages/router/src/__tests__/hooks.test.tsx index 369fbd1..4731939 100644 --- a/packages/router/src/__tests__/hooks.test.tsx +++ b/packages/router/src/__tests__/hooks.test.tsx @@ -47,6 +47,28 @@ describe("hooks", () => { expect(screen.getByTestId("hash").textContent).toBe("#section"); }); + it("returns entryId and entryKey from Navigation API", () => { + function TestComponent() { + const location = useLocation(); + return ( +
+ {location.entryId ?? "null"} + {location.entryKey ?? "null"} +
+ ); + } + + const routes: RouteDefinition[] = [ + { path: "/", component: TestComponent }, + ]; + + render(); + + // The mock navigation generates UUIDs for id and key + expect(screen.getByTestId("entryId").textContent).not.toBe("null"); + expect(screen.getByTestId("entryKey").textContent).not.toBe("null"); + }); + it("throws when used outside Router", () => { function TestComponent() { useLocation(); diff --git a/packages/router/src/context/RouterContext.ts b/packages/router/src/context/RouterContext.ts index 3f04d00..46477ca 100644 --- a/packages/router/src/context/RouterContext.ts +++ b/packages/router/src/context/RouterContext.ts @@ -14,6 +14,10 @@ export type RouterContextValue = { locationInfo: unknown; /** Current URL (null during SSR) */ url: URL | null; + /** NavigationHistoryEntry.id — stable unique identifier (null during SSR or when Navigation API is unavailable) */ + entryId: string | null; + /** NavigationHistoryEntry.key — unique key that changes on replace (null during SSR or when Navigation API is unavailable) */ + entryKey: string | null; /** Whether a navigation transition is pending */ isPending: boolean; /** Navigate to a new URL and wait for completion */ diff --git a/packages/router/src/core/NavigationAPIAdapter.ts b/packages/router/src/core/NavigationAPIAdapter.ts index 92c27ab..4e30b6a 100644 --- a/packages/router/src/core/NavigationAPIAdapter.ts +++ b/packages/router/src/core/NavigationAPIAdapter.ts @@ -64,6 +64,8 @@ export class NavigationAPIAdapter implements RouterAdapter { this.#cachedSnapshot = { url: new URL(entry.url), key: this.#effectiveKey(entry.id), + entryId: entry.id, + entryKey: entry.key, state: entry.getState(), info: this.#currentNavigationInfo, }; diff --git a/packages/router/src/core/RouterAdapter.ts b/packages/router/src/core/RouterAdapter.ts index 9dfaf43..ac02744 100644 --- a/packages/router/src/core/RouterAdapter.ts +++ b/packages/router/src/core/RouterAdapter.ts @@ -20,6 +20,10 @@ export type LocationEntry = { url: URL; /** Unique key for this entry (used for loader caching) */ key: string; + /** NavigationHistoryEntry.id — stable unique identifier for this entry */ + entryId: string | null; + /** NavigationHistoryEntry.key — unique key that changes when entry is replaced */ + entryKey: string | null; /** State associated with this entry */ state: unknown; /** Ephemeral info from current navigation (undefined if not from navigation event) */ diff --git a/packages/router/src/core/StaticAdapter.ts b/packages/router/src/core/StaticAdapter.ts index d85458d..5c111cc 100644 --- a/packages/router/src/core/StaticAdapter.ts +++ b/packages/router/src/core/StaticAdapter.ts @@ -28,6 +28,8 @@ export class StaticAdapter implements RouterAdapter { this.#cachedSnapshot = { url: new URL(window.location.href), key: "__static__", + entryId: null, + entryKey: null, state: undefined, info: undefined, }; diff --git a/packages/router/src/hooks/useLocation.ts b/packages/router/src/hooks/useLocation.ts index 41ed04f..dc2c83f 100644 --- a/packages/router/src/hooks/useLocation.ts +++ b/packages/router/src/hooks/useLocation.ts @@ -12,7 +12,7 @@ export function useLocation(): Location { throw new Error("useLocation must be used within a Router"); } - const { url } = context; + const { url, entryId, entryKey } = context; if (url === null) { throw new Error("useLocation: URL is not available during SSR."); @@ -23,6 +23,8 @@ export function useLocation(): Location { pathname: url.pathname, search: url.search, hash: url.hash, + entryId, + entryKey, }; - }, [url]); + }, [url, entryId, entryKey]); } diff --git a/packages/router/src/types.ts b/packages/router/src/types.ts index 366e5eb..340ffa2 100644 --- a/packages/router/src/types.ts +++ b/packages/router/src/types.ts @@ -123,6 +123,10 @@ export type Location = { pathname: string; search: string; hash: string; + /** NavigationHistoryEntry.id — stable unique identifier. Null when Navigation API is unavailable. */ + entryId: string | null; + /** NavigationHistoryEntry.key — unique key that changes when entry is replaced. Null when Navigation API is unavailable. */ + entryKey: string | null; }; /** From 14933e7259d2b75188cc8fda04e300011b33211b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Mar 2026 00:22:20 +0000 Subject: [PATCH 2/5] docs: update Location type and useLocation docs with entryId/entryKey https://claude.ai/code/session_014uqruYL8PktVwDVrSA6HJQ --- packages/docs/src/pages/ApiHooksPage.tsx | 2 ++ packages/docs/src/pages/ApiTypesPage.tsx | 8 ++++++++ 2 files changed, 10 insertions(+) diff --git a/packages/docs/src/pages/ApiHooksPage.tsx b/packages/docs/src/pages/ApiHooksPage.tsx index 79dbbce..8a1ff32 100644 --- a/packages/docs/src/pages/ApiHooksPage.tsx +++ b/packages/docs/src/pages/ApiHooksPage.tsx @@ -21,6 +21,8 @@ function MyComponent() { console.log(location.pathname); // "/users/123" console.log(location.search); // "?tab=profile" console.log(location.hash); // "#section" + console.log(location.entryId); // NavigationHistoryEntry.id or null + console.log(location.entryKey); // NavigationHistoryEntry.key or null }`} diff --git a/packages/docs/src/pages/ApiTypesPage.tsx b/packages/docs/src/pages/ApiTypesPage.tsx index 3bbbe02..1357946 100644 --- a/packages/docs/src/pages/ApiTypesPage.tsx +++ b/packages/docs/src/pages/ApiTypesPage.tsx @@ -335,7 +335,15 @@ routeState<{ tab: string }>()({ pathname: string; search: string; hash: string; + entryId: string | null; // NavigationHistoryEntry.id + entryKey: string | null; // NavigationHistoryEntry.key }`} +

+ entryId and entryKey expose the + corresponding properties from the Navigation API's{" "} + NavigationHistoryEntry. They are null when + the Navigation API is unavailable (e.g., in static fallback mode). +

From f5f2b6650f4053713b9f1d3089a432c46f2ee7a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Mar 2026 00:37:12 +0000 Subject: [PATCH 3/5] fix: correct entryId/entryKey JSDoc and add hydration mismatch warning entryId is unique per entry and changes on replacement. entryKey represents the slot and is stable across replacements. Also warns against rendering these values in DOM since they are unavailable during SSR. https://claude.ai/code/session_014uqruYL8PktVwDVrSA6HJQ --- packages/docs/src/pages/ApiHooksPage.tsx | 7 +++++++ packages/docs/src/pages/ApiTypesPage.tsx | 13 +++++++++++-- packages/router/src/context/RouterContext.ts | 4 ++-- packages/router/src/core/RouterAdapter.ts | 4 ++-- packages/router/src/types.ts | 20 ++++++++++++++++++-- 5 files changed, 40 insertions(+), 8 deletions(-) diff --git a/packages/docs/src/pages/ApiHooksPage.tsx b/packages/docs/src/pages/ApiHooksPage.tsx index 8a1ff32..1a2d140 100644 --- a/packages/docs/src/pages/ApiHooksPage.tsx +++ b/packages/docs/src/pages/ApiHooksPage.tsx @@ -24,6 +24,13 @@ function MyComponent() { console.log(location.entryId); // NavigationHistoryEntry.id or null console.log(location.entryKey); // NavigationHistoryEntry.key or null }`} +

+ entryId and entryKey are null{" "} + when the Navigation API is unavailable. Do not render them directly in + DOM — they are not available during SSR and will cause a hydration + mismatch. Use them as a React key or in effects/callbacks + instead. +

diff --git a/packages/docs/src/pages/ApiTypesPage.tsx b/packages/docs/src/pages/ApiTypesPage.tsx index 1357946..0f8e814 100644 --- a/packages/docs/src/pages/ApiTypesPage.tsx +++ b/packages/docs/src/pages/ApiTypesPage.tsx @@ -341,8 +341,17 @@ routeState<{ tab: string }>()({

entryId and entryKey expose the corresponding properties from the Navigation API's{" "} - NavigationHistoryEntry. They are null when - the Navigation API is unavailable (e.g., in static fallback mode). + NavigationHistoryEntry. entryId is a unique + identifier for the entry — a new id is assigned when the entry is + replaced. entryKey represents the slot in the entry list + and is stable across replacements. Both are null when the + Navigation API is unavailable (e.g., in static fallback mode). +

+

+ Warning: Do not render these values directly in DOM, + as they are not available during SSR and will cause a hydration + mismatch. They are best suited for use as a React key or + in effects/callbacks.

diff --git a/packages/router/src/context/RouterContext.ts b/packages/router/src/context/RouterContext.ts index 46477ca..f41286f 100644 --- a/packages/router/src/context/RouterContext.ts +++ b/packages/router/src/context/RouterContext.ts @@ -14,9 +14,9 @@ export type RouterContextValue = { locationInfo: unknown; /** Current URL (null during SSR) */ url: URL | null; - /** NavigationHistoryEntry.id — stable unique identifier (null during SSR or when Navigation API is unavailable) */ + /** NavigationHistoryEntry.id — unique identifier for this entry. A new id is assigned when the entry is replaced. Null during SSR or when Navigation API is unavailable. */ entryId: string | null; - /** NavigationHistoryEntry.key — unique key that changes on replace (null during SSR or when Navigation API is unavailable) */ + /** NavigationHistoryEntry.key — represents the slot in the entry list. Stable across replacements. Null during SSR or when Navigation API is unavailable. */ entryKey: string | null; /** Whether a navigation transition is pending */ isPending: boolean; diff --git a/packages/router/src/core/RouterAdapter.ts b/packages/router/src/core/RouterAdapter.ts index ac02744..414e1fe 100644 --- a/packages/router/src/core/RouterAdapter.ts +++ b/packages/router/src/core/RouterAdapter.ts @@ -20,9 +20,9 @@ export type LocationEntry = { url: URL; /** Unique key for this entry (used for loader caching) */ key: string; - /** NavigationHistoryEntry.id — stable unique identifier for this entry */ + /** NavigationHistoryEntry.id — unique identifier for this entry. A new id is assigned when the entry is replaced. */ entryId: string | null; - /** NavigationHistoryEntry.key — unique key that changes when entry is replaced */ + /** NavigationHistoryEntry.key — represents the slot in the entry list. Stable across replacements. */ entryKey: string | null; /** State associated with this entry */ state: unknown; diff --git a/packages/router/src/types.ts b/packages/router/src/types.ts index 340ffa2..f902410 100644 --- a/packages/router/src/types.ts +++ b/packages/router/src/types.ts @@ -123,9 +123,25 @@ export type Location = { pathname: string; search: string; hash: string; - /** NavigationHistoryEntry.id — stable unique identifier. Null when Navigation API is unavailable. */ + /** + * NavigationHistoryEntry.id — unique identifier for this entry. + * A new id is assigned when the entry is replaced. + * Null when Navigation API is unavailable. + * + * **Warning:** Do not render this value directly in DOM, as it is not + * available during SSR and will cause a hydration mismatch. Use it as a + * React `key` or in effects/callbacks instead. + */ entryId: string | null; - /** NavigationHistoryEntry.key — unique key that changes when entry is replaced. Null when Navigation API is unavailable. */ + /** + * NavigationHistoryEntry.key — represents the slot in the entry list. + * Stable across replacements. + * Null when Navigation API is unavailable. + * + * **Warning:** Do not render this value directly in DOM, as it is not + * available during SSR and will cause a hydration mismatch. Use it as a + * React `key` or in effects/callbacks instead. + */ entryKey: string | null; }; From 6f777c32dbe44a31f9aa7c7de429a56166160c68 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Mar 2026 00:41:57 +0000 Subject: [PATCH 4/5] fix: resolve entryId/entryKey from server snapshot during hydration Use the same pattern as locationKey to inspect the server snapshot's actual location entry during the first hydration render. https://claude.ai/code/session_014uqruYL8PktVwDVrSA6HJQ --- packages/router/src/Router/index.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/router/src/Router/index.tsx b/packages/router/src/Router/index.tsx index fa45ee3..8273477 100644 --- a/packages/router/src/Router/index.tsx +++ b/packages/router/src/Router/index.tsx @@ -263,8 +263,18 @@ export function Router({ const locationState = locationEntry?.state; const locationInfo = locationEntry?.info; - const entryId = locationEntry?.entryId ?? null; - const entryKey = locationEntry?.entryKey ?? null; + const entryId = + locationEntry?.entryId ?? + (isServerSnapshot(locationEntryInternal) + ? locationEntryInternal.actualLocationEntry?.entryId + : null) ?? + null; + const entryKey = + locationEntry?.entryKey ?? + (isServerSnapshot(locationEntryInternal) + ? locationEntryInternal.actualLocationEntry?.entryKey + : null) ?? + null; const routerContextValue: RouterContextValue = useMemo( () => ({ locationState, From 3761f0b7dfbaea1c0a05fd15f90029597e8900fe Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Mar 2026 00:55:55 +0000 Subject: [PATCH 5/5] test: add entryId/entryKey null assertions for static fallback and SSR Covers the cases where Navigation API is unavailable (static fallback mode) and during SSR, verifying entryId and entryKey are null. https://claude.ai/code/session_014uqruYL8PktVwDVrSA6HJQ --- .../router/src/__tests__/fallback.test.tsx | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/packages/router/src/__tests__/fallback.test.tsx b/packages/router/src/__tests__/fallback.test.tsx index 9cb8920..0b0d914 100644 --- a/packages/router/src/__tests__/fallback.test.tsx +++ b/packages/router/src/__tests__/fallback.test.tsx @@ -143,6 +143,26 @@ describe("Fallback Mode", () => { expect(screen.getByTestId("hash").textContent).toBe("#section"); }); + it("returns null for entryId and entryKey when Navigation API is unavailable", () => { + setupStaticLocation("http://localhost/"); + + function Page() { + const location = useLocation(); + return ( +
+ {location.entryId ?? "null"} + {location.entryKey ?? "null"} +
+ ); + } + + const routes: RouteDefinition[] = [{ path: "/", component: Page }]; + + render(); + expect(screen.getByTestId("entryId").textContent).toBe("null"); + expect(screen.getByTestId("entryKey").textContent).toBe("null"); + }); + it("renders nothing when no route matches", () => { setupStaticLocation("http://localhost/unknown"); @@ -431,6 +451,24 @@ describe("ssr", () => { expect(screen.getByTestId("hash").textContent).toBe(""); }); + it("returns null for entryId and entryKey during SSR", () => { + function Page() { + const location = useLocation(); + return ( +
+ {location.entryId ?? "null"} + {location.entryKey ?? "null"} +
+ ); + } + + const routes: RouteDefinition[] = [{ path: "/about", component: Page }]; + + render(); + expect(screen.getByTestId("entryId").textContent).toBe("null"); + expect(screen.getByTestId("entryKey").textContent).toBe("null"); + }); + it("pathless route wrapping path-based children works with ssr.path", () => { const routes: RouteDefinition[] = [ {