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
9 changes: 9 additions & 0 deletions packages/docs/src/pages/ApiHooksPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
}`}</CodeBlock>
<p>
<code>entryId</code> and <code>entryKey</code> are <code>null</code>{" "}
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 <code>key</code> or in effects/callbacks
instead.
Comment on lines +31 to +32
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The text recommends using entryId/entryKey as a React key, but because these values are unavailable during SSR, using them as keys in SSR-rendered output can still produce hydration/reconciliation problems when they change on the client. Consider narrowing the recommendation to effects/callbacks (or only client-only rendering) instead of keys.

Suggested change
mismatch. Use them as a React <code>key</code> or in effects/callbacks
instead.
mismatch. Use them in effects or callbacks instead.

Copilot uses AI. Check for mistakes.
</p>
</article>

<article className="api-item">
Expand Down
17 changes: 17 additions & 0 deletions packages/docs/src/pages/ApiTypesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -335,7 +335,24 @@ routeState<{ tab: string }>()({
pathname: string;
search: string;
hash: string;
entryId: string | null; // NavigationHistoryEntry.id
entryKey: string | null; // NavigationHistoryEntry.key
}`}</CodeBlock>
<p>
<code>entryId</code> and <code>entryKey</code> expose the
corresponding properties from the Navigation API's{" "}
<code>NavigationHistoryEntry</code>. <code>entryId</code> is a unique
identifier for the entry — a new id is assigned when the entry is
replaced. <code>entryKey</code> represents the slot in the entry list
and is stable across replacements. Both are <code>null</code> when the
Navigation API is unavailable (e.g., in static fallback mode).
</p>
<p>
<strong>Warning:</strong> 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 <code>key</code> or
in effects/callbacks.
Comment on lines +351 to +354
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This warning suggests these values are “best suited for use as a React key”, but if entryId/entryKey are null during SSR and become non-null on the client, using them as keys in SSR-rendered trees can still cause hydration/reconciliation issues. Consider adjusting the guidance to emphasize use in effects/callbacks (or only in client-only renders) rather than as keys in SSR output.

Suggested change
<strong>Warning:</strong> 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 <code>key</code> or
in effects/callbacks.
<strong>Warning:</strong> Do not render these values directly into the
DOM or use them as React <code>key</code> props in trees that are
server-rendered, as they are not available during SSR and will cause
hydration mismatches. Prefer using them in effects/callbacks or in
client-only components.

Copilot uses AI. Check for mistakes.
</p>
</article>

<article className="api-item">
Expand Down
16 changes: 16 additions & 0 deletions packages/router/src/Router/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment on lines +266 to +271
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

entryId is being populated from ServerLocationSnapshot.actualLocationEntry during hydration (when locationEntryInternal is a server snapshot). That makes entryId non-null on the first client render even though it was null on the server, which violates the documented “null during SSR/hydration” contract and can trigger hydration/key mismatches. Consider returning null for entryId while isServerSnapshot(locationEntryInternal) (and only exposing it once locationEntryInternal has synced to the real client snapshot).

Suggested change
const entryId =
locationEntry?.entryId ??
(isServerSnapshot(locationEntryInternal)
? locationEntryInternal.actualLocationEntry?.entryId
: null) ??
null;
const entryId = isServerSnapshot(locationEntryInternal)
? null
: locationEntry?.entryId ?? null;

Copilot uses AI. Check for mistakes.
const entryKey =
locationEntry?.entryKey ??
(isServerSnapshot(locationEntryInternal)
? locationEntryInternal.actualLocationEntry?.entryKey
: null) ??
null;
Comment on lines +272 to +277
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue as entryId: entryKey is read from ServerLocationSnapshot.actualLocationEntry during hydration, so it can differ between server HTML and the first client render. Since this value is explicitly documented as unavailable during SSR, it should stay null while locationEntryInternal is a server snapshot to avoid hydration/key mismatches.

Suggested change
const entryKey =
locationEntry?.entryKey ??
(isServerSnapshot(locationEntryInternal)
? locationEntryInternal.actualLocationEntry?.entryKey
: null) ??
null;
const entryKey = isServerSnapshot(locationEntryInternal)
? null
: locationEntry?.entryKey ?? null;

Copilot uses AI. Check for mistakes.
const routerContextValue: RouterContextValue = useMemo(
() => ({
locationState,
locationInfo,
url: urlObject,
entryId,
entryKey,
isPending,
navigateAsync,
updateCurrentEntryState,
Expand All @@ -276,6 +290,8 @@ export function Router({
locationState,
locationInfo,
urlObject,
entryId,
entryKey,
isPending,
navigateAsync,
updateCurrentEntryState,
Expand Down
38 changes: 38 additions & 0 deletions packages/router/src/__tests__/fallback.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div>
<span data-testid="entryId">{location.entryId ?? "null"}</span>
<span data-testid="entryKey">{location.entryKey ?? "null"}</span>
</div>
);
}

const routes: RouteDefinition[] = [{ path: "/", component: Page }];

render(<Router routes={routes} fallback="static" />);
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");

Expand Down Expand Up @@ -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 (
<div>
<span data-testid="entryId">{location.entryId ?? "null"}</span>
<span data-testid="entryKey">{location.entryKey ?? "null"}</span>
</div>
);
}

const routes: RouteDefinition[] = [{ path: "/about", component: Page }];

render(<Router routes={routes} ssr={{ path: "/about" }} />);
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[] = [
{
Expand Down
22 changes: 22 additions & 0 deletions packages/router/src/__tests__/hooks.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div>
<span data-testid="entryId">{location.entryId ?? "null"}</span>
<span data-testid="entryKey">{location.entryKey ?? "null"}</span>
</div>
);
}

const routes: RouteDefinition[] = [
{ path: "/", component: TestComponent },
];

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

// 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");
});
Comment on lines +50 to +70
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test coverage currently only asserts that entryId/entryKey are non-null with the Navigation API mock. Since the public contract says these are null when the Navigation API is unavailable and during SSR/hydration, it would be good to add assertions for those cases as well (e.g., static fallback mode and an SSR+hydrate scenario) to prevent regressions—especially given the hydration-mismatch warning in the docs.

Copilot uses AI. Check for mistakes.

it("throws when used outside Router", () => {
function TestComponent() {
useLocation();
Expand Down
4 changes: 4 additions & 0 deletions packages/router/src/context/RouterContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
2 changes: 2 additions & 0 deletions packages/router/src/core/NavigationAPIAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down
4 changes: 4 additions & 0 deletions packages/router/src/core/RouterAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) */
Expand Down
2 changes: 2 additions & 0 deletions packages/router/src/core/StaticAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down
6 changes: 4 additions & 2 deletions packages/router/src/hooks/useLocation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.");
Expand All @@ -23,6 +23,8 @@ export function useLocation(): Location {
pathname: url.pathname,
search: url.search,
hash: url.hash,
entryId,
entryKey,
};
}, [url]);
}, [url, entryId, entryKey]);
}
20 changes: 20 additions & 0 deletions packages/router/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment on lines +129 to +143
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docstring says “Null when Navigation API is unavailable”, but the warning immediately below references SSR/hydration. Consider updating the main description to also state “null during SSR/hydration”, and reconsider the suggestion to “Use it as a React key”: keys participate in reconciliation/hydration, so a value that’s null on the server and non-null on the client can still cause hydration issues even if it’s not rendered to the DOM.

Suggested change
* 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.
* Null when Navigation API is unavailable or during SSR/hydration.
*
* **Warning:** Do not render this value directly in DOM, as it is not
* available during SSR and will cause a hydration mismatch. Avoid using it
* as a React `key` in SSR/hydrated trees; prefer using it only in
* effects/callbacks or other client-only logic instead.
*/
entryId: string | null;
/**
* NavigationHistoryEntry.key represents the slot in the entry list.
* Stable across replacements.
* Null when Navigation API is unavailable or during SSR/hydration.
*
* **Warning:** Do not render this value directly in DOM, as it is not
* available during SSR and will cause a hydration mismatch. Avoid using it
* as a React `key` in SSR/hydrated trees; prefer using it only in
* effects/callbacks or other client-only logic instead.

Copilot uses AI. Check for mistakes.
*/
entryKey: string | null;
};

/**
Expand Down
Loading