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
13 changes: 9 additions & 4 deletions packages/router/src/Router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,12 +99,17 @@ export function Router({
setLocationEntry(initialEntry);
}

// Subscribe to navigation changes (wrapped in transition)
// Subscribe to navigation changes (conditionally wrapped in transition)
useEffect(() => {
return adapter.subscribe(() => {
startTransition(() => {
return adapter.subscribe((changeType) => {
if (changeType === "navigation") {
startTransition(() => {
setLocationEntry(adapter.getSnapshot());
});
} else {
// State-only update: apply synchronously, no transition
setLocationEntry(adapter.getSnapshot());
});
}
});
}, [adapter, startTransition]);

Expand Down
37 changes: 28 additions & 9 deletions packages/router/src/__tests__/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,10 @@ export function createMockNavigation(initialUrl = "http://localhost/") {
// Store info for testing and dispatch navigate event with info
mockNavigation.__lastNavigateInfo = options?.info;

const previousEntry = currentEntry;
const navigationType =
options?.history === "replace" ? "replace" : "push";

if (options?.history !== "replace") {
// When pushing a new entry, dispose all entries after current position
// This mimics browser behavior when navigating forward from a back state
Expand Down Expand Up @@ -155,8 +159,12 @@ export function createMockNavigation(initialUrl = "http://localhost/") {

mockNavigation.currentEntry = currentEntry;

// Dispatch currententrychange event
dispatchEvent("currententrychange", new Event("currententrychange"));
// Dispatch currententrychange event with navigationType
const event = Object.assign(new Event("currententrychange"), {
navigationType,
from: previousEntry,
});
dispatchEvent("currententrychange", event);

return {
committed: Promise.resolve(currentEntry),
Expand All @@ -167,8 +175,12 @@ export function createMockNavigation(initialUrl = "http://localhost/") {

updateCurrentEntry: vi.fn((options: { state: unknown }) => {
currentEntry.__updateState(options.state);
// Dispatch currententrychange event to notify subscribers
dispatchEvent("currententrychange", new Event("currententrychange"));
// Dispatch currententrychange event with navigationType: null (state-only update)
const event = Object.assign(new Event("currententrychange"), {
navigationType: null,
from: currentEntry,
});
dispatchEvent("currententrychange", event);
}),

addEventListener: vi.fn(
Expand Down Expand Up @@ -212,6 +224,7 @@ export function createMockNavigation(initialUrl = "http://localhost/") {
event,
proceed: () => {
if (!event.defaultPrevented) {
const previousEntry = currentEntry;
// Dispose entries after current position (browser behavior)
const currentIndex = entries.indexOf(currentEntry);
while (entries.length > currentIndex + 1) {
Expand All @@ -226,10 +239,11 @@ export function createMockNavigation(initialUrl = "http://localhost/") {
entries.push(newEntry);
currentEntry = newEntry;
mockNavigation.currentEntry = currentEntry;
dispatchEvent(
"currententrychange",
new Event("currententrychange"),
);
const changeEvent = Object.assign(new Event("currententrychange"), {
navigationType: "push" as const,
from: previousEntry,
});
dispatchEvent("currententrychange", changeEvent);
}
},
};
Expand All @@ -241,9 +255,14 @@ export function createMockNavigation(initialUrl = "http://localhost/") {
if (entryIndex < 0 || entryIndex >= entries.length) {
throw new Error(`Invalid entry index: ${entryIndex}`);
}
const previousEntry = currentEntry;
currentEntry = entries[entryIndex];
mockNavigation.currentEntry = currentEntry;
dispatchEvent("currententrychange", new Event("currententrychange"));
const event = Object.assign(new Event("currententrychange"), {
navigationType: "traverse" as const,
from: previousEntry,
});
dispatchEvent("currententrychange", event);
},

// Test helper to get listeners
Expand Down
93 changes: 93 additions & 0 deletions packages/router/src/__tests__/state.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,99 @@ describe("Navigation State Management", () => {
});
});

describe("isPending behavior", () => {
it("setStateSync does not trigger isPending", () => {
type PageState = { count: number };
const isPendingValues: boolean[] = [];

function Page({
state,
setStateSync,
isPending,
}: RouteComponentProps<Record<string, never>, PageState>) {
isPendingValues.push(isPending);
return (
<div>
<span>Count: {state?.count ?? 0}</span>
<span>Pending: {isPending ? "yes" : "no"}</span>
<button onClick={() => setStateSync({ count: 5 })}>Set 5</button>
</div>
);
}

const routes = [
routeState<PageState>()({
path: "/",
component: Page,
}),
];

render(<Router routes={routes} />);
expect(screen.getByText("Pending: no")).toBeInTheDocument();

// Clear recorded values before the action
isPendingValues.length = 0;

act(() => {
fireEvent.click(screen.getByRole("button"));
});

expect(screen.getByText("Count: 5")).toBeInTheDocument();
expect(screen.getByText("Pending: no")).toBeInTheDocument();
// isPending should never have been true during the update
expect(isPendingValues.every((v) => v === false)).toBe(true);
});

it("resetState does not trigger isPending", () => {
type PageState = { count: number };
const isPendingValues: boolean[] = [];

function Page({
state,
setStateSync,
resetState,
isPending,
}: RouteComponentProps<Record<string, never>, PageState>) {
isPendingValues.push(isPending);
return (
<div>
<span>Count: {state?.count ?? "none"}</span>
<span>Pending: {isPending ? "yes" : "no"}</span>
<button onClick={() => setStateSync({ count: 10 })}>Set</button>
<button onClick={resetState}>Reset</button>
</div>
);
}

const routes = [
routeState<PageState>()({
path: "/",
component: Page,
}),
];

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

// Set some state first
act(() => {
fireEvent.click(screen.getByText("Set"));
});
expect(screen.getByText("Count: 10")).toBeInTheDocument();

// Clear recorded values before reset
isPendingValues.length = 0;

act(() => {
fireEvent.click(screen.getByText("Reset"));
});

expect(screen.getByText("Count: none")).toBeInTheDocument();
expect(screen.getByText("Pending: no")).toBeInTheDocument();
// isPending should never have been true during the reset
expect(isPendingValues.every((v) => v === false)).toBe(true);
});
});

describe("backward compatibility", () => {
it("routes without state type still work", () => {
function Page({ params }: { params: { id: string } }) {
Expand Down
24 changes: 19 additions & 5 deletions packages/router/src/core/NavigationAPIAdapter.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import type { RouterAdapter, LocationEntry } from "./RouterAdapter.js";
import type {
RouterAdapter,
LocationEntry,
EntryChangeType,
} from "./RouterAdapter.js";
import type {
InternalRouteDefinition,
NavigateOptions,
Expand Down Expand Up @@ -59,11 +63,21 @@ export class NavigationAPIAdapter implements RouterAdapter {
return this.#cachedSnapshot;
}

subscribe(callback: () => void): () => void {
subscribe(callback: (changeType: EntryChangeType) => void): () => void {
const controller = new AbortController();
navigation.addEventListener("currententrychange", callback, {
signal: controller.signal,
});
navigation.addEventListener(
"currententrychange",
(event) => {
// NavigationCurrentEntryChangeEvent.navigationType is null
// when the change was caused by updateCurrentEntry()
const changeType: EntryChangeType =
(event as NavigationCurrentEntryChangeEvent).navigationType === null
? "state"
: "navigation";
callback(changeType);
},
{ signal: controller.signal },
);

// Subscribe to dispose events on all existing entries
this.#subscribeToDisposeEvents(controller.signal);
Expand Down
8 changes: 6 additions & 2 deletions packages/router/src/core/NullAdapter.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import type { RouterAdapter, LocationEntry } from "./RouterAdapter.js";
import type {
RouterAdapter,
LocationEntry,
EntryChangeType,
} from "./RouterAdapter.js";
import type {
InternalRouteDefinition,
NavigateOptions,
Expand All @@ -16,7 +20,7 @@ export class NullAdapter implements RouterAdapter {
return null;
}

subscribe(_callback: () => void): () => void {
subscribe(_callback: (changeType: EntryChangeType) => void): () => void {
return () => {};
}

Expand Down
10 changes: 9 additions & 1 deletion packages/router/src/core/RouterAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ import type {
OnNavigateCallback,
} from "../types.js";

/**
* The type of change that caused a location entry update.
* - "navigation": A URL navigation (push, replace, reload, traverse)
* - "state": A state-only update via updateCurrentEntry()
*/
export type EntryChangeType = "navigation" | "state";

/**
* Represents the current location state.
* Abstracts NavigationHistoryEntry for static mode compatibility.
Expand Down Expand Up @@ -32,9 +39,10 @@ export interface RouterAdapter {

/**
* Subscribe to location changes.
* The callback receives the type of change that occurred.
* Returns an unsubscribe function.
*/
subscribe(callback: () => void): () => void;
subscribe(callback: (changeType: EntryChangeType) => void): () => void;

/**
* Perform programmatic navigation.
Expand Down
8 changes: 6 additions & 2 deletions packages/router/src/core/StaticAdapter.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import type { RouterAdapter, LocationEntry } from "./RouterAdapter.js";
import type {
RouterAdapter,
LocationEntry,
EntryChangeType,
} from "./RouterAdapter.js";
import type {
InternalRouteDefinition,
NavigateOptions,
Expand Down Expand Up @@ -31,7 +35,7 @@ export class StaticAdapter implements RouterAdapter {
return this.#cachedSnapshot;
}

subscribe(_callback: () => void): () => void {
subscribe(_callback: (changeType: EntryChangeType) => void): () => void {
// Static mode never fires location change events
return () => {};
}
Expand Down
19 changes: 17 additions & 2 deletions packages/router/src/navigation-api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ interface NavigateEvent extends Event {
scroll(): void;
}

interface NavigationCurrentEntryChangeEvent extends Event {
readonly navigationType: "push" | "replace" | "reload" | "traverse" | null;
readonly from: NavigationHistoryEntry;
}

interface NavigationInterceptOptions {
handler?: () => Promise<void>;
precommitHandler?: () => Promise<void>;
Expand Down Expand Up @@ -80,7 +85,12 @@ interface Navigation extends EventTarget {
options?: AddEventListenerOptions,
): void;
addEventListener(
type: "navigatesuccess" | "navigateerror" | "currententrychange",
type: "currententrychange",
listener: (event: NavigationCurrentEntryChangeEvent) => void,
options?: AddEventListenerOptions,
): void;
addEventListener(
type: "navigatesuccess" | "navigateerror",
listener: (event: Event) => void,
options?: AddEventListenerOptions,
): void;
Expand All @@ -90,7 +100,12 @@ interface Navigation extends EventTarget {
options?: EventListenerOptions,
): void;
removeEventListener(
type: "navigatesuccess" | "navigateerror" | "currententrychange",
type: "currententrychange",
listener: (event: NavigationCurrentEntryChangeEvent) => void,
options?: EventListenerOptions,
): void;
removeEventListener(
type: "navigatesuccess" | "navigateerror",
listener: (event: Event) => void,
options?: EventListenerOptions,
): void;
Expand Down