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
3 changes: 0 additions & 3 deletions src/components/navigation/AppLink.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,6 @@ export function AppLink({
) {
return;
}
if (target === "_blank") {
return;
}

event.preventDefault();
void beginTransition(to, transitionOptions).catch(() => {
Expand Down
40 changes: 30 additions & 10 deletions src/navigation/RouteTransitionContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@ import {
type To,
} from "react-router-dom";
import { useMotionProfile } from "../hooks/useMotionProfile";
import { preloadRoute, resolveNavigatePath } from "./routePreloaders";
import {
preloadRoute,
resolveNavigateDestinationKey,
resolveNavigatePath,
} from "./routePreloaders";
import {
revealRouteTransition,
type NavRevealDirection,
Expand Down Expand Up @@ -56,6 +60,8 @@ export function RouteTransitionProvider({ children }: { children: ReactNode }) {
const screenReadyRef = useRef(true);
const loadingTargetRef = useRef<string | null>(null);
const pathnameRef = useRef(location.pathname);
const transitionGenerationRef = useRef(0);
const revealDirectionRef = useRef<NavRevealDirection>("forward");

useEffect(() => {
phaseRef.current = phase;
Expand Down Expand Up @@ -92,6 +98,7 @@ export function RouteTransitionProvider({ children }: { children: ReactNode }) {
const beginTransition = useCallback(
async (to: To, options?: BeginTransitionOptions) => {
const targetPath = resolveNavigatePath(to);
const destinationKey = resolveNavigateDestinationKey(to);
const navigateOptions = {
replace: options?.replace,
state: options?.state,
Expand All @@ -102,23 +109,33 @@ export function RouteTransitionProvider({ children }: { children: ReactNode }) {

if (
phaseRef.current !== "idle" &&
loadingTargetRef.current === targetPath
loadingTargetRef.current === destinationKey
) {
return;
}

const myGeneration = ++transitionGenerationRef.current;
revealDirectionRef.current = toRevealDirection(options?.direction);

if (phaseRef.current !== "idle") {
loadingTargetRef.current = destinationKey;
try {
await preloadRoute(targetPath);
} catch {
// Warm-up only; the rendered lazy route retries chunk failures itself.
}

if (transitionGenerationRef.current !== myGeneration) {
return;
}

navigate(to, navigateOptions);
phaseRef.current = "idle";
setPhase("idle");
return;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

loadingTargetRef.current = targetPath;
loadingTargetRef.current = destinationKey;
screenReadyRef.current = false;
phaseRef.current = "loading";
setPhase("loading");
Expand All @@ -134,20 +151,23 @@ export function RouteTransitionProvider({ children }: { children: ReactNode }) {

await waitForScreenReady();

if (transitionGenerationRef.current !== myGeneration) {
return;
}

phaseRef.current = "revealing";
setPhase("revealing");
try {
await revealRouteTransition(
toRevealDirection(options?.direction),
animate,
);
await revealRouteTransition(revealDirectionRef.current, animate);
} catch {
// Navigation succeeded; a failed reveal should not block the route.
}
} finally {
loadingTargetRef.current = null;
phaseRef.current = "idle";
setPhase("idle");
if (transitionGenerationRef.current === myGeneration) {
loadingTargetRef.current = null;
phaseRef.current = "idle";
setPhase("idle");
}
}
},
[animate, navigate, waitForScreenReady],
Expand Down
7 changes: 2 additions & 5 deletions src/navigation/RouteTransitionOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,15 @@ export function RouteTransitionOverlay() {
}

return (
<div
<output
className="route-transition-overlay"
role="status"
aria-busy="true"
aria-live="polite"
aria-atomic="true"
aria-label="Loading page"
>
<div className="route-transition-overlay-content">
<LoadingSpinnerRing size="md" className="text-brand-blue" />
<span className="route-transition-overlay-label">Loading…</span>
</div>
</div>
</output>
);
}
25 changes: 25 additions & 0 deletions src/navigation/routePreloaders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,31 @@ export function resolveNavigatePath(to: To): string {
return normalizeRoutePath(to.pathname ?? "/");
}

export function resolveNavigateDestinationKey(to: To): string {
if (typeof to === "string") {
const hashIndex = to.indexOf("#");
const queryIndex = to.indexOf("?");
const pathEnd = Math.min(
hashIndex === -1 ? to.length : hashIndex,
queryIndex === -1 ? to.length : queryIndex,
);
const pathPart = to.slice(0, pathEnd) || "/";
const queryPart =
queryIndex === -1
? ""
: to.slice(queryIndex, hashIndex === -1 ? undefined : hashIndex);
const hashPart = hashIndex === -1 ? "" : to.slice(hashIndex);

return `${normalizeRoutePath(pathPart)}${queryPart}${hashPart}`;
}

const pathname = normalizeRoutePath(to.pathname ?? "/");
const search = to.search ?? "";
const hash = to.hash ?? "";

return `${pathname}${search}${hash}`;
}

export function isLazyRoute(path: string): boolean {
return normalizeRoutePath(path) in LAZY_ROUTE_LOADERS;
}
Expand Down
51 changes: 44 additions & 7 deletions src/navigation/routeTransition.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
isLazyRoute,
normalizeRoutePath,
preloadRoute,
resolveNavigateDestinationKey,
} from "./routePreloaders";
import { routeReadinessKind } from "./useRouteScreenReady";

Expand All @@ -25,6 +26,40 @@ describe("normalizeRoutePath", () => {
});
});

describe("resolveNavigateDestinationKey", () => {
it("preserves query strings and hashes for deduplication", () => {
expect(resolveNavigateDestinationKey("/map?session=abc")).toBe(
"/map?session=abc",
);
expect(resolveNavigateDestinationKey("/create#step-2")).toBe(
"/create#step-2",
);
expect(resolveNavigateDestinationKey("/map?session=abc#panel")).toBe(
"/map?session=abc#panel",
);
});

it("preserves object search and hash", () => {
expect(
resolveNavigateDestinationKey({
pathname: "/map",
search: "?session=abc",
hash: "#panel",
}),
).toBe("/map?session=abc#panel");
});

it("defaults omitted object search and hash to empty", () => {
expect(resolveNavigateDestinationKey({ pathname: "/join" })).toBe("/join");
});

it("normalizes preset edit paths while preserving query and hash", () => {
expect(
resolveNavigateDestinationKey("/presets/abc123/edit?tab=rules#top"),
).toBe("/presets/:id/edit?tab=rules#top");
});
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

describe("isLazyRoute", () => {
it("marks known lazy routes", () => {
expect(isLazyRoute("/map")).toBe(true);
Expand Down Expand Up @@ -55,14 +90,16 @@ describe("preloadRoute", () => {
it("invokes the lazy loader for /map and query-bearing paths", async () => {
const mapLoader = vi.spyOn(routePreloaders.routeImporter, "importMapScreen");

await preloadRoute("/map");
expect(mapLoader).toHaveBeenCalledTimes(1);

mapLoader.mockClear();
await preloadRoute("/map?session=abc");
expect(mapLoader).toHaveBeenCalledTimes(1);
try {
await preloadRoute("/map");
expect(mapLoader).toHaveBeenCalledTimes(1);

mapLoader.mockRestore();
mapLoader.mockClear();
await preloadRoute("/map?session=abc");
expect(mapLoader).toHaveBeenCalledTimes(1);
} finally {
mapLoader.mockRestore();
}
});
});

Expand Down
Loading