Skip to content
Open
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
67 changes: 51 additions & 16 deletions packages/vinext/src/server/app-browser-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,19 +23,22 @@ import { notifyAppRouterTransitionStart } from "../client/instrumentation-client
import {
__basePath,
activateNavigationSnapshot,
clearPendingPathname,
commitClientNavigationState,
consumePrefetchResponse,
createClientNavigationRenderSnapshot,
getClientNavigationRenderContext,
getClientNavigationState,
getPrefetchCache,
getPrefetchedUrls,
pushHistoryStateWithoutNotify,
replaceClientParamsWithoutNotify,
replaceHistoryStateWithoutNotify,
restoreRscResponse,
setClientParams,
snapshotRscResponse,
setPendingPathname,
setNavigationContext,
snapshotRscResponse,
toRscUrl,
type CachedRscResponse,
type ClientNavigationRenderSnapshot,
Expand Down Expand Up @@ -171,8 +174,15 @@ function drainPrePaintEffects(upToRenderId: number): void {
function createNavigationCommitEffect(
href: string,
historyUpdateMode: HistoryUpdateMode | undefined,
navId: number,
): () => void {
return () => {
// Only update URL if this is still the active navigation.
// A newer navigation would have incremented activeNavigationId.
if (navId !== activeNavigationId) {
return;
}

const targetHref = new URL(href, window.location.origin).href;

if (historyUpdateMode === "replace" && window.location.href !== targetHref) {
Expand All @@ -181,7 +191,7 @@ function createNavigationCommitEffect(
pushHistoryStateWithoutNotify(null, "", href);
}

commitClientNavigationState();
commitClientNavigationState(navId);
};
}

Expand Down Expand Up @@ -334,6 +344,7 @@ function updateBrowserTree(
renderId: number,
useTransitionMode: boolean,
snapshotActivated = false,
navId?: number,
): void {
const setter = getBrowserTreeStateSetter();

Expand All @@ -351,7 +362,11 @@ function updateBrowserTree(
const resolve = pendingNavigationCommits.get(renderId);
pendingNavigationCommits.delete(renderId);
if (snapshotActivated) {
commitClientNavigationState();
if (navId !== undefined) {
commitClientNavigationState(navId);
} else {
commitClientNavigationState();
}
}
resolve?.();
};
Expand Down Expand Up @@ -383,6 +398,7 @@ function renderNavigationPayload(
navigationSnapshot: ClientNavigationRenderSnapshot,
prePaintEffect: (() => void) | null = null,
useTransition = true,
navId?: number,
): Promise<void> {
const renderId = ++nextNavigationRenderId;
queuePrePaintNavigationEffect(renderId, prePaintEffect);
Expand All @@ -396,13 +412,17 @@ function renderNavigationPayload(
// Wrap updateBrowserTree in try-catch to ensure counter is decremented
// if a synchronous error occurs before the async promise chain is established.
try {
updateBrowserTree(payload, navigationSnapshot, renderId, useTransition, true);
updateBrowserTree(payload, navigationSnapshot, renderId, useTransition, true, navId);
} catch (error) {
// Clean up pending state and decrement counter on synchronous error.
pendingNavigationPrePaintEffects.delete(renderId);
const resolve = pendingNavigationCommits.get(renderId);
pendingNavigationCommits.delete(renderId);
commitClientNavigationState();
if (navId !== undefined) {
commitClientNavigationState(navId);
} else {
commitClientNavigationState();
}
resolve?.();
throw error; // Re-throw to maintain error propagation
}
Expand Down Expand Up @@ -613,19 +633,26 @@ async function main(): Promise<void> {
try {
const url = new URL(href, window.location.origin);
const rscUrl = toRscUrl(url.pathname + url.search);
// Use startTransition for same-route navigations (searchParam changes)
// so React keeps the old UI visible during the transition. For cross-route
// navigations (different pathname), use synchronous updates — React's
// startTransition hangs in Firefox when replacing the entire tree.
// NB: During rapid navigations, window.location.pathname may not reflect
// the previous navigation's URL yet (URL commit is deferred). This could
// cause misclassification (synchronous instead of startTransition or vice
// versa), resulting in slightly less smooth transitions but correct behavior.
const isSameRoute =
stripBasePath(url.pathname, __basePath) ===

// Get navigation state for comparison
const navState = getClientNavigationState();

// Compare against previous pending navigation, then committed, then window.location
// This ensures correct isSameRoute classification during rapid successive navigations
const currentPath =
navState?.pendingPathname ??
navState?.cachedPathname ??
stripBasePath(window.location.pathname, __basePath);

const targetPath = stripBasePath(url.pathname, __basePath);
const isSameRoute = targetPath === currentPath;

// Set this navigation as the pending pathname, overwriting any previous.
// Pass navId so only this navigation (or a newer one) can clear it later.
setPendingPathname(url.pathname, navId);

const cachedRoute = getVisitedResponse(rscUrl, navigationKind);
const navigationCommitEffect = createNavigationCommitEffect(href, historyUpdateMode);
const navigationCommitEffect = createNavigationCommitEffect(href, historyUpdateMode, navId);

if (cachedRoute) {
// Check stale-navigation before and after createFromFetch. The pre-check
Expand Down Expand Up @@ -660,6 +687,7 @@ async function main(): Promise<void> {
cachedNavigationSnapshot,
navigationCommitEffect,
isSameRoute,
navId,
);
} finally {
// Always clear _snapshotPending so the outer catch does not
Expand Down Expand Up @@ -744,6 +772,7 @@ async function main(): Promise<void> {
navigationSnapshot,
navigationCommitEffect,
isSameRoute,
navId,
);
} finally {
// Always clear _snapshotPending after renderNavigationPayload returns or
Expand All @@ -767,6 +796,12 @@ async function main(): Promise<void> {
_snapshotPending = false;
commitClientNavigationState();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This commitClientNavigationState() call (without navId) runs when _snapshotPending is true in the outer catch. It happens to be safe because commitClientNavigationState(undefined) won't match any pendingPathnameNavId that's a number — the condition state.pendingPathnameNavId === navId will be number === undefined which is false.

But this is fragile and relies on an implicit type mismatch. If someone later changes the navId parameter type or the clearing condition, this could silently start clobbering pending pathnames from other navigations.

Since navId is available in scope here, it would be safer and more consistent to pass it:

Suggested change
commitClientNavigationState();
commitClientNavigationState(navId);

This makes the intent explicit and matches the pattern used in createNavigationCommitEffect, renderNavigationPayload, and updateBrowserTree.

}
// Clear pending pathname on error so subsequent navigations compare correctly.
// Only clear if this is still the active navigation — a newer navigation
// has already overwritten pendingPathname with its own target.
if (navId === activeNavigationId) {
clearPendingPathname(navId);
}
// Don't hard-navigate to a stale URL if this navigation was superseded by
// a newer one — the newer navigation is already in flight and would be clobbered.
if (navId !== activeNavigationId) return;
Expand Down
43 changes: 41 additions & 2 deletions packages/vinext/src/shims/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -474,6 +474,8 @@ type ClientNavigationState = {
clientParamsJson: string;
pendingClientParams: Record<string, string | string[]> | null;
pendingClientParamsJson: string | null;
pendingPathname: string | null;
pendingPathnameNavId: number | null;
originalPushState: typeof window.history.pushState;
originalReplaceState: typeof window.history.replaceState;
patchInstalled: boolean;
Expand All @@ -486,7 +488,7 @@ type ClientNavigationGlobal = typeof globalThis & {
[_CLIENT_NAV_STATE_KEY]?: ClientNavigationState;
};

function getClientNavigationState(): ClientNavigationState | null {
export function getClientNavigationState(): ClientNavigationState | null {
if (isServer) return null;

const globalState = window as ClientNavigationGlobal;
Expand All @@ -499,6 +501,8 @@ function getClientNavigationState(): ClientNavigationState | null {
clientParamsJson: "{}",
pendingClientParams: null,
pendingClientParamsJson: null,
pendingPathname: null,
pendingPathnameNavId: null,
// NB: These capture the currently installed history methods, not guaranteed
// native ones. If a third-party library (analytics, router) has already patched
// history methods before this module loads, we intentionally preserve that
Expand Down Expand Up @@ -729,6 +733,34 @@ export function getClientParams(): Record<string, string | string[]> {
return getClientNavigationState()?.clientParams ?? _fallbackClientParams;
}

/**
* Set the pending pathname for client-side navigation.
* Strips the base path before storing. Associates the pathname with the given navId
* so only that navigation (or a newer one) can clear it.
*/
export function setPendingPathname(pathname: string, navId: number): void {
const state = getClientNavigationState();
if (!state) return;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Minor: the isServer guard here is redundant. getClientNavigationState() on line 739 already returns null when isServer is true (line 491), so this collapses to just if (!state) return — which is what clearPendingPathname uses at line 749. Consider making them consistent:

Suggested change
if (!state) return;
if (!state) return;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Still unaddressed from both prior reviews: getClientNavigationState() already returns null when isServer is true (line 491), so the isServer check here is redundant. clearPendingPathname at line 749 only checks if (!state) — these should be consistent.

Suggested change
if (!state) return;
if (!state) return;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Flagged in all three prior review rounds and still unaddressed. getClientNavigationState() returns null when isServer is true (line 491), so the isServer check is redundant. clearPendingPathname at line 755 only checks if (!state) return — these two functions should be consistent.

Suggested change
if (!state) return;
if (!state) return;

state.pendingPathname = stripBasePath(pathname, __basePath);
state.pendingPathnameNavId = navId;
}

/**
* Clear the pending pathname, but only if the given navId matches the one
* that set it, or if pendingPathnameNavId is null (no active owner).
* This prevents superseded navigations from clearing state belonging to newer navigations.
*/
export function clearPendingPathname(navId: number): void {
const state = getClientNavigationState();
if (!state) return;
// Only clear if this navId is the one that set the pendingPathname,
// or if pendingPathnameNavId is null (no owner)
if (state.pendingPathnameNavId === null || state.pendingPathnameNavId === navId) {
state.pendingPathname = null;
state.pendingPathnameNavId = null;
}
}

function getClientParamsSnapshot(): Record<string, string | string[]> {
return getClientNavigationState()?.clientParams ?? _EMPTY_PARAMS;
}
Expand Down Expand Up @@ -888,7 +920,7 @@ function withSuppressedUrlNotifications<T>(fn: () => T): T {
}
}

export function commitClientNavigationState(): void {
export function commitClientNavigationState(navId?: number): void {
if (isServer) return;
const state = getClientNavigationState();
if (!state) return;
Expand All @@ -908,6 +940,13 @@ export function commitClientNavigationState(): void {
state.pendingClientParams = null;
state.pendingClientParamsJson = null;
}
// Clear pending pathname when navigation commits, but only if:
// - The navId matches the one that set pendingPathname
// - No newer navigation has overwritten pendingPathname (pendingPathnameNavId === null or matches)
if (state.pendingPathnameNavId === null || state.pendingPathnameNavId === navId) {
state.pendingPathname = null;
state.pendingPathnameNavId = null;
}
const shouldNotify = urlChanged || state.hasPendingNavigationUpdate;
state.hasPendingNavigationUpdate = false;

Expand Down
Loading
Loading