From 9adf495f17e6732149053b6fdc7f833aee6f6fc2 Mon Sep 17 00:00:00 2001 From: gelbh Date: Mon, 13 Jul 2026 12:36:32 +0100 Subject: [PATCH 1/2] fix(motion): address route transition CodeRabbit follow-ups --- src/components/navigation/AppLink.tsx | 3 -- src/navigation/RouteTransitionContext.tsx | 36 ++++++++++++++------ src/navigation/RouteTransitionOverlay.tsx | 7 ++-- src/navigation/routePreloaders.ts | 25 ++++++++++++++ src/navigation/routeTransition.test.ts | 41 +++++++++++++++++++---- 5 files changed, 87 insertions(+), 25 deletions(-) diff --git a/src/components/navigation/AppLink.tsx b/src/components/navigation/AppLink.tsx index f422c1b..df0ddb7 100644 --- a/src/components/navigation/AppLink.tsx +++ b/src/components/navigation/AppLink.tsx @@ -69,9 +69,6 @@ export function AppLink({ ) { return; } - if (target === "_blank") { - return; - } event.preventDefault(); void beginTransition(to, transitionOptions).catch(() => { diff --git a/src/navigation/RouteTransitionContext.tsx b/src/navigation/RouteTransitionContext.tsx index feea676..cb0fc61 100644 --- a/src/navigation/RouteTransitionContext.tsx +++ b/src/navigation/RouteTransitionContext.tsx @@ -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, @@ -56,6 +60,8 @@ export function RouteTransitionProvider({ children }: { children: ReactNode }) { const screenReadyRef = useRef(true); const loadingTargetRef = useRef(null); const pathnameRef = useRef(location.pathname); + const transitionGenerationRef = useRef(0); + const revealDirectionRef = useRef("forward"); useEffect(() => { phaseRef.current = phase; @@ -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, @@ -102,12 +109,16 @@ 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 { @@ -115,10 +126,12 @@ export function RouteTransitionProvider({ children }: { children: ReactNode }) { } navigate(to, navigateOptions); + phaseRef.current = "idle"; + setPhase("idle"); return; } - loadingTargetRef.current = targetPath; + loadingTargetRef.current = destinationKey; screenReadyRef.current = false; phaseRef.current = "loading"; setPhase("loading"); @@ -134,20 +147,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], diff --git a/src/navigation/RouteTransitionOverlay.tsx b/src/navigation/RouteTransitionOverlay.tsx index a6f58d1..d0f6435 100644 --- a/src/navigation/RouteTransitionOverlay.tsx +++ b/src/navigation/RouteTransitionOverlay.tsx @@ -9,18 +9,15 @@ export function RouteTransitionOverlay() { } return ( -
Loading…
-
+ ); } diff --git a/src/navigation/routePreloaders.ts b/src/navigation/routePreloaders.ts index 2b961bd..e227dcd 100644 --- a/src/navigation/routePreloaders.ts +++ b/src/navigation/routePreloaders.ts @@ -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; } diff --git a/src/navigation/routeTransition.test.ts b/src/navigation/routeTransition.test.ts index 7fcad2d..509eb6c 100644 --- a/src/navigation/routeTransition.test.ts +++ b/src/navigation/routeTransition.test.ts @@ -4,6 +4,7 @@ import { isLazyRoute, normalizeRoutePath, preloadRoute, + resolveNavigateDestinationKey, } from "./routePreloaders"; import { routeReadinessKind } from "./useRouteScreenReady"; @@ -25,6 +26,30 @@ 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"); + }); +}); + describe("isLazyRoute", () => { it("marks known lazy routes", () => { expect(isLazyRoute("/map")).toBe(true); @@ -55,14 +80,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(); + } }); }); From 50c73fd57c289b6141fe366177405bd2c3157ffb Mon Sep 17 00:00:00 2001 From: gelbh Date: Mon, 13 Jul 2026 12:45:47 +0100 Subject: [PATCH 2/2] fix(motion): guard fast-path transition against stale preload --- src/navigation/RouteTransitionContext.tsx | 4 ++++ src/navigation/routeTransition.test.ts | 10 ++++++++++ 2 files changed, 14 insertions(+) diff --git a/src/navigation/RouteTransitionContext.tsx b/src/navigation/RouteTransitionContext.tsx index cb0fc61..e90adb3 100644 --- a/src/navigation/RouteTransitionContext.tsx +++ b/src/navigation/RouteTransitionContext.tsx @@ -125,6 +125,10 @@ export function RouteTransitionProvider({ children }: { children: ReactNode }) { // Warm-up only; the rendered lazy route retries chunk failures itself. } + if (transitionGenerationRef.current !== myGeneration) { + return; + } + navigate(to, navigateOptions); phaseRef.current = "idle"; setPhase("idle"); diff --git a/src/navigation/routeTransition.test.ts b/src/navigation/routeTransition.test.ts index 509eb6c..bdc24b5 100644 --- a/src/navigation/routeTransition.test.ts +++ b/src/navigation/routeTransition.test.ts @@ -48,6 +48,16 @@ describe("resolveNavigateDestinationKey", () => { }), ).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"); + }); }); describe("isLazyRoute", () => {