diff --git a/packages/docs/src/pages/ApiHooksPage.tsx b/packages/docs/src/pages/ApiHooksPage.tsx index 79dbbce..1a2d140 100644 --- a/packages/docs/src/pages/ApiHooksPage.tsx +++ b/packages/docs/src/pages/ApiHooksPage.tsx @@ -21,7 +21,16 @@ 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 }`} +

+ 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 3bbbe02..0f8e814 100644 --- a/packages/docs/src/pages/ApiTypesPage.tsx +++ b/packages/docs/src/pages/ApiTypesPage.tsx @@ -335,7 +335,24 @@ 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. 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/Router/index.tsx b/packages/router/src/Router/index.tsx index 9511667..8273477 100644 --- a/packages/router/src/Router/index.tsx +++ b/packages/router/src/Router/index.tsx @@ -263,11 +263,25 @@ export function Router({ const locationState = locationEntry?.state; const locationInfo = locationEntry?.info; + 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, locationInfo, url: urlObject, + entryId, + entryKey, isPending, navigateAsync, updateCurrentEntryState, @@ -276,6 +290,8 @@ export function Router({ locationState, locationInfo, urlObject, + entryId, + entryKey, isPending, navigateAsync, updateCurrentEntryState, 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[] = [ { 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..f41286f 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 — 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 — 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; /** 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..414e1fe 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 — unique identifier for this entry. A new id is assigned when the entry is replaced. */ + entryId: string | null; + /** NavigationHistoryEntry.key — represents the slot in the entry list. Stable across replacements. */ + 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..f902410 100644 --- a/packages/router/src/types.ts +++ b/packages/router/src/types.ts @@ -123,6 +123,26 @@ export type Location = { pathname: string; search: string; hash: string; + /** + * 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 — 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; }; /**