From e4b0db468df02f0af613bcdb8065ea36ddc34782 Mon Sep 17 00:00:00 2001 From: gelbh Date: Mon, 13 Jul 2026 11:49:01 +0100 Subject: [PATCH] feat(motion): add route transition gate with HUD overlay --- CHANGELOG.md | 2 +- src/App.tsx | 183 +++++++++--------- src/components/billing/PremiumTierCards.tsx | 6 +- src/components/legal/LegalDocumentPage.tsx | 6 +- src/components/legal/LegalInlineLinks.tsx | 10 +- src/components/navigation/AppLink.tsx | 39 ++++ src/components/presets/BundledPresetTree.tsx | 6 +- src/components/presets/PresetBrowseLayout.tsx | 6 +- src/components/presets/PresetDetailPanel.tsx | 14 +- .../presets/PresetSearchResults.tsx | 14 +- src/components/session/MapFirstRunSheet.tsx | 6 +- src/components/tutorial/TutorialHub.tsx | 6 +- .../tutorial/TutorialSectionWizard.tsx | 6 +- src/components/ui/MapErrorBoundary.tsx | 6 +- src/components/ui/ScreenHeader.tsx | 6 +- src/components/ui/ScreenNav.tsx | 10 +- src/domain/device/changelog.ts | 2 +- src/hooks/useAppNavigate.test.tsx | 68 ++++--- src/hooks/useAppNavigate.ts | 40 ++-- src/index.css | 1 + src/navigation/AppNavigate.tsx | 25 +++ src/navigation/RouteReadinessSensor.tsx | 16 ++ src/navigation/RouteTransitionContext.tsx | 158 +++++++++++++++ src/navigation/RouteTransitionOverlay.tsx | 24 +++ src/navigation/revealRouteTransition.ts | 16 ++ src/navigation/routePreloaders.ts | 61 ++++++ src/navigation/routeTransition.test.ts | 70 +++++++ .../routeTransitionContextInstance.ts | 17 ++ src/navigation/useRouteScreenReady.ts | 48 +++++ src/navigation/useRouteTransition.ts | 13 ++ src/routes/AdminMapScreen.tsx | 6 +- src/routes/AdminPanel.tsx | 6 +- src/routes/CreateSession.test.tsx | 14 +- src/routes/GamePresets.test.tsx | 12 +- src/routes/GamePresets.tsx | 11 +- src/routes/HiderMapScreen.tsx | 4 +- src/routes/Home.tsx | 46 +++-- src/routes/MapScreen.test.tsx | 8 +- src/routes/MapScreen.tsx | 4 +- src/routes/ObserverMapScreen.tsx | 6 +- .../create-session/PremiumGateSection.tsx | 6 +- .../ObserverMapScreenChrome.tsx | 6 +- src/styles/route-transition.css | 78 ++++++++ src/test/RouteTransitionTestProvider.tsx | 51 +++++ src/test/renderWithRouter.tsx | 3 +- 45 files changed, 879 insertions(+), 267 deletions(-) create mode 100644 src/components/navigation/AppLink.tsx create mode 100644 src/navigation/AppNavigate.tsx create mode 100644 src/navigation/RouteReadinessSensor.tsx create mode 100644 src/navigation/RouteTransitionContext.tsx create mode 100644 src/navigation/RouteTransitionOverlay.tsx create mode 100644 src/navigation/revealRouteTransition.ts create mode 100644 src/navigation/routePreloaders.ts create mode 100644 src/navigation/routeTransition.test.ts create mode 100644 src/navigation/routeTransitionContextInstance.ts create mode 100644 src/navigation/useRouteScreenReady.ts create mode 100644 src/navigation/useRouteTransition.ts create mode 100644 src/styles/route-transition.css create mode 100644 src/test/RouteTransitionTestProvider.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index daf48deb..fd5526c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,7 @@ ### Improvements -- Admin map: mission-control layout on tablet and desktop with a collapsible side rail for diagnostics, sync, stats, map debug, log, chat, and moderation. +- Motion: route changes show a loading overlay until the destination screen is ready, then play the existing slide transition. - Motion: unified sheet and panel drag physics, iOS-calibrated motion tokens, and expanded tap feedback with Vibration API fallback on web. - Motion: iOS-style route push/pop transitions, global edge-swipe back, and unified session exit so map teardown runs after the home transition. - Motion: retuned mobile sheets, wizard steps, and map panel snap to iOS-calibrated timing on the critical path. diff --git a/src/App.tsx b/src/App.tsx index ae21591d..5346ce9a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,8 +2,6 @@ import { Suspense, useEffect, type ReactNode } from "react"; import * as Sentry from "@sentry/react"; import { BrowserRouter, - Link, - Navigate, Route, Routes, useLocation, @@ -14,6 +12,7 @@ import { AppUpdateBanner } from "./components/ui/AppUpdateBanner"; import { AppUpdateProvider } from "./components/ui/AppUpdateProvider"; import { LowBatteryPrompt } from "./components/session/LowBatteryPrompt"; import { MotionDatasetEffect } from "./components/motion/MotionDatasetEffect"; +import { AppLink } from "./components/navigation/AppLink"; import { Home } from "./routes/Home"; import { AdminPanel } from "./routes/AdminPanel"; import { JoinSession } from "./routes/JoinSession"; @@ -29,29 +28,23 @@ import { } from "./domain/device/chunkLoadRecovery"; import { getServiceWorkerChunkReloadContext, - lazyWithChunkRetry, setChunkReloadContextGetter, } from "./domain/device/lazyWithChunkRetry"; import { notifyAppNeedRefresh } from "./domain/device/serviceWorkerRefresh"; import { useEdgeSwipeBack } from "./hooks/useEdgeSwipeBack"; import { pruneStaleTimerSessions } from "./services/session/sessionCleanup"; import { useSessionStore } from "./state/sessionStore"; - -const MapScreen = lazyWithChunkRetry(() => - import("./routes/MapScreen").then((m) => ({ default: m.MapScreen })), -); -const CreateSession = lazyWithChunkRetry(() => - import("./routes/CreateSession").then((m) => ({ default: m.CreateSession })), -); -const GamePresetList = lazyWithChunkRetry(() => - import("./routes/GamePresets").then((m) => ({ default: m.GamePresetList })), -); -const GamePresetEditor = lazyWithChunkRetry(() => - import("./routes/GamePresets").then((m) => ({ default: m.GamePresetEditor })), -); -const Tutorial = lazyWithChunkRetry(() => - import("./routes/Tutorial").then((m) => ({ default: m.Tutorial })), -); +import { AppNavigate } from "./navigation/AppNavigate"; +import { RouteReadinessSensor } from "./navigation/RouteReadinessSensor"; +import { RouteTransitionOverlay } from "./navigation/RouteTransitionOverlay"; +import { RouteTransitionProvider } from "./navigation/RouteTransitionContext"; +import { + CreateSessionLazy, + GamePresetEditorLazy, + GamePresetListLazy, + MapScreenLazy, + TutorialLazy, +} from "./navigation/routePreloaders"; function RouteFallback() { return ( @@ -130,12 +123,12 @@ function AppErrorFallback() { > Reload - Back home - + ); @@ -159,78 +152,82 @@ export default function App() { return ( - - }> - - - - - -
- - - } /> - - - - } - /> - } /> - } /> - } /> - } /> - - - - } - /> - } /> - } /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - - - } - /> - } /> - -
-
-
+ + + }> + + + + + + + +
+ + + } /> + + + + } + /> + } /> + } /> + } /> + } /> + + + + } + /> + } /> + } /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + + + } + /> + } /> + +
+
+
+
); } diff --git a/src/components/billing/PremiumTierCards.tsx b/src/components/billing/PremiumTierCards.tsx index c0ce7887..c7ffa5e4 100644 --- a/src/components/billing/PremiumTierCards.tsx +++ b/src/components/billing/PremiumTierCards.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { Link } from "react-router-dom"; +import { AppLink } from "../navigation/AppLink"; import { SegmentControl } from "../ui/SegmentControl"; import { formatBankedPremiumSessionCreditsLabel, @@ -191,14 +191,14 @@ export function PremiumTierCards({ ) : null} {entitlements?.canCreatePremium ? ( - Create premium session {createSessionHint} - + ) : null} diff --git a/src/components/legal/LegalDocumentPage.tsx b/src/components/legal/LegalDocumentPage.tsx index 8b5f4ae5..4551ef6b 100644 --- a/src/components/legal/LegalDocumentPage.tsx +++ b/src/components/legal/LegalDocumentPage.tsx @@ -1,4 +1,4 @@ -import { Link } from "react-router-dom"; +import { AppLink } from "../navigation/AppLink"; import { EntryScreenLayout } from "../ui/EntryScreenLayout"; import { ScreenHeader, @@ -77,12 +77,12 @@ export function LegalDocumentPage({

See also{" "} - {otherLabel} - + .

diff --git a/src/components/legal/LegalInlineLinks.tsx b/src/components/legal/LegalInlineLinks.tsx index 484735af..dce889a5 100644 --- a/src/components/legal/LegalInlineLinks.tsx +++ b/src/components/legal/LegalInlineLinks.tsx @@ -1,23 +1,23 @@ -import { Link } from "react-router-dom"; +import { AppLink } from "../navigation/AppLink"; import { LEGAL_PRIVACY_PATH, LEGAL_TERMS_PATH } from "../../domain/legal/legalContact"; export function LegalInlineLinks() { return (

See{" "} - Terms - {" "} + {" "} and{" "} - Privacy - + .

); diff --git a/src/components/navigation/AppLink.tsx b/src/components/navigation/AppLink.tsx new file mode 100644 index 00000000..31395abd --- /dev/null +++ b/src/components/navigation/AppLink.tsx @@ -0,0 +1,39 @@ +import { + Link, + type LinkProps, +} from "react-router-dom"; +import { useRouteTransition } from "../../navigation/useRouteTransition"; + +export function AppLink({ to, onClick, target, ...props }: LinkProps) { + const { beginTransition } = useRouteTransition(); + + return ( + { + onClick?.(event); + if (event.defaultPrevented) { + return; + } + if ( + event.metaKey || + event.ctrlKey || + event.shiftKey || + event.altKey || + event.button !== 0 + ) { + return; + } + if (target === "_blank") { + return; + } + + event.preventDefault(); + void beginTransition(to); + }} + /> + ); +} diff --git a/src/components/presets/BundledPresetTree.tsx b/src/components/presets/BundledPresetTree.tsx index de0d6e9a..82dc7894 100644 --- a/src/components/presets/BundledPresetTree.tsx +++ b/src/components/presets/BundledPresetTree.tsx @@ -1,5 +1,5 @@ import { useId, useMemo, useState } from "react"; -import { Link } from "react-router-dom"; +import { AppLink } from "../navigation/AppLink"; import { BUNDLED_GAME_PRESET_DEFINITIONS, bundledPresetDefinition, @@ -29,12 +29,12 @@ function PresetLeafCard({ preset }: { preset: MigratedPreset }) { description={description} headerAction={} actions={ - Host - + } /> ); diff --git a/src/components/presets/PresetBrowseLayout.tsx b/src/components/presets/PresetBrowseLayout.tsx index 402c85ac..6b33af59 100644 --- a/src/components/presets/PresetBrowseLayout.tsx +++ b/src/components/presets/PresetBrowseLayout.tsx @@ -1,4 +1,4 @@ -import { Link } from "react-router-dom"; +import { AppLink } from "../navigation/AppLink"; import { ScreenHeader, screenHeaderOffsetClassName, @@ -59,9 +59,9 @@ export function PresetBrowseLayout({ /> - + New preset - + {searching ? ( diff --git a/src/components/presets/PresetDetailPanel.tsx b/src/components/presets/PresetDetailPanel.tsx index 0ae987ba..f683bcd6 100644 --- a/src/components/presets/PresetDetailPanel.tsx +++ b/src/components/presets/PresetDetailPanel.tsx @@ -1,4 +1,4 @@ -import { Link } from "react-router-dom"; +import { AppLink } from "../navigation/AppLink"; import { bundledPresetDefinition, isBundledPresetId, @@ -52,28 +52,28 @@ export function PresetDetailPanel({
{preset.migrationStatus === "manual_required" ? ( - Review - + ) : ( - Host - + )} {!bundled ? ( <> - Edit - + - + Full tutorial → - +
); diff --git a/src/components/tutorial/TutorialHub.tsx b/src/components/tutorial/TutorialHub.tsx index 9cd8a9ca..9ffe497c 100644 --- a/src/components/tutorial/TutorialHub.tsx +++ b/src/components/tutorial/TutorialHub.tsx @@ -1,4 +1,4 @@ -import { Link } from "react-router-dom"; +import { AppLink } from "../navigation/AppLink"; import { getTutorialSections, type TutorialSection, @@ -131,10 +131,10 @@ export function TutorialHub({ {otherSections.map(renderSectionButton)} - + Create session Start hosting - + ); } diff --git a/src/components/tutorial/TutorialSectionWizard.tsx b/src/components/tutorial/TutorialSectionWizard.tsx index 5a0a5f37..a26b70a8 100644 --- a/src/components/tutorial/TutorialSectionWizard.tsx +++ b/src/components/tutorial/TutorialSectionWizard.tsx @@ -1,5 +1,5 @@ import { useCallback, useMemo, useState } from "react"; -import { Link } from "react-router-dom"; +import { AppLink } from "../navigation/AppLink"; import { HudToolIcon } from "../map/ToolIcons"; import { ToolStepper } from "../tools/shared/ToolStepper"; import { WizardSwipeSurface } from "../tools/shared/WizardSwipeSurface"; @@ -165,9 +165,9 @@ export function TutorialSectionWizard({ · - + Create session - + · diff --git a/src/components/ui/MapErrorBoundary.tsx b/src/components/ui/MapErrorBoundary.tsx index 357df689..8cbc6830 100644 --- a/src/components/ui/MapErrorBoundary.tsx +++ b/src/components/ui/MapErrorBoundary.tsx @@ -1,5 +1,5 @@ import { Component, type ErrorInfo, type ReactNode } from "react"; -import { Link } from "react-router-dom"; +import { AppLink } from "../navigation/AppLink"; import { clearChunkReloadFlag, hasChunkReloadBeenAttempted, @@ -94,12 +94,12 @@ export class MapErrorBoundary extends Component< ) : null} )} - {appUpdateCopy.mapErrorBackHome} - + ); } diff --git a/src/components/ui/ScreenHeader.tsx b/src/components/ui/ScreenHeader.tsx index 1eeb28dd..12cd54d6 100644 --- a/src/components/ui/ScreenHeader.tsx +++ b/src/components/ui/ScreenHeader.tsx @@ -1,4 +1,4 @@ -import { Link } from "react-router-dom"; +import { AppLink } from "../navigation/AppLink"; import { AppLogo } from "./AppLogo"; type ScreenHeaderPlacement = "fixed" | "sticky" | "inline"; @@ -46,7 +46,7 @@ export function ScreenHeader({ className = "", }: ScreenHeaderProps) { const backControl = ( - ← {backLabel} - + ); const content = ( diff --git a/src/components/ui/ScreenNav.tsx b/src/components/ui/ScreenNav.tsx index 8a4d0a9a..76138dfe 100644 --- a/src/components/ui/ScreenNav.tsx +++ b/src/components/ui/ScreenNav.tsx @@ -1,4 +1,4 @@ -import { Link } from "react-router-dom"; +import { AppLink } from "../navigation/AppLink"; import { HudHomeIcon } from "./HudIcons"; import { screenBackLinkClassName, @@ -46,17 +46,17 @@ export function ScreenNav({ return ( ); diff --git a/src/domain/device/changelog.ts b/src/domain/device/changelog.ts index 95018ae1..eab85dc1 100644 --- a/src/domain/device/changelog.ts +++ b/src/domain/device/changelog.ts @@ -33,7 +33,7 @@ export const CHANGELOG: ChangelogEntry[] = [ { title: "Improvements", items: [ - "Admin map: mission-control layout on tablet and desktop with a collapsible side rail for diagnostics, sync, stats, map debug, log, chat, and moderation.", + "Motion: route changes show a loading overlay until the destination screen is ready, then play the existing slide transition.", "Motion: unified sheet and panel drag physics, iOS-calibrated motion tokens, and expanded tap feedback with Vibration API fallback on web.", "Motion: iOS-style route push/pop transitions, global edge-swipe back, and unified session exit so map teardown runs after the home transition.", "Motion: retuned mobile sheets, wizard steps, and map panel snap to iOS-calibrated timing on the critical path.", diff --git a/src/hooks/useAppNavigate.test.tsx b/src/hooks/useAppNavigate.test.tsx index 560e1871..5ed4162e 100644 --- a/src/hooks/useAppNavigate.test.tsx +++ b/src/hooks/useAppNavigate.test.tsx @@ -1,28 +1,29 @@ import { renderHook, act } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { useAppNavigate, resetAppNavigationStackForTests, useAppNavigationStack } from "./useAppNavigate"; - -const navigateMock = vi.fn(); - -vi.mock("react-router-dom", async () => { - const actual = await vi.importActual( - "react-router-dom", - ); - return { - ...actual, - useNavigate: () => navigateMock, - }; -}); +import { + useAppNavigate, + resetAppNavigationStackForTests, + useAppNavigationStack, +} from "./useAppNavigate"; + +const beginTransitionMock = vi.fn(async () => undefined); + +vi.mock("../navigation/useRouteTransition", () => ({ + useRouteTransition: () => ({ + phase: "idle" as const, + beginTransition: beginTransitionMock, + reportScreenReady: vi.fn(), + }), +})); describe("useAppNavigate", () => { beforeEach(() => { - navigateMock.mockReset(); + beginTransitionMock.mockReset(); resetAppNavigationStackForTests("/"); - delete document.documentElement.dataset.navDirection; }); - it("sets forward nav direction and enables view transitions by default", () => { + it("delegates forward navigation to the transition coordinator", () => { const { result } = renderHook(() => useAppNavigate(), { wrapper: ({ children }) => {children}, }); @@ -31,13 +32,16 @@ describe("useAppNavigate", () => { result.current("/create"); }); - expect(document.documentElement.dataset.navDirection).toBe("forward"); - expect(navigateMock).toHaveBeenCalledWith("/create", { - viewTransition: true, + expect(beginTransitionMock).toHaveBeenCalledWith("/create", { + direction: "forward", + replace: undefined, + state: undefined, + preventScrollReset: undefined, + relative: undefined, }); }); - it("uses neutral direction for replace navigations", () => { + it("uses replace direction for replace navigations", () => { const { result } = renderHook(() => useAppNavigate(), { wrapper: ({ children }) => {children}, }); @@ -46,14 +50,16 @@ describe("useAppNavigate", () => { result.current("/", { replace: true }); }); - expect(document.documentElement.dataset.navDirection).toBe("neutral"); - expect(navigateMock).toHaveBeenCalledWith("/", { + expect(beginTransitionMock).toHaveBeenCalledWith("/", { + direction: "replace", replace: true, - viewTransition: true, + state: undefined, + preventScrollReset: undefined, + relative: undefined, }); }); - it("uses back nav direction when requested", () => { + it("uses back direction when requested", () => { const { result } = renderHook(() => useAppNavigate(), { wrapper: ({ children }) => {children}, }); @@ -63,9 +69,12 @@ describe("useAppNavigate", () => { result.current("/", { direction: "back" }); }); - expect(document.documentElement.dataset.navDirection).toBe("back"); - expect(navigateMock).toHaveBeenLastCalledWith("/", { - viewTransition: true, + expect(beginTransitionMock).toHaveBeenLastCalledWith("/", { + direction: "back", + replace: undefined, + state: undefined, + preventScrollReset: undefined, + relative: undefined, }); }); @@ -89,9 +98,8 @@ describe("useAppNavigate", () => { stackResult.current.goBack(); }); - expect(navigateMock).toHaveBeenLastCalledWith("/", { - viewTransition: true, + expect(beginTransitionMock).toHaveBeenLastCalledWith("/", { + direction: "back", }); - expect(document.documentElement.dataset.navDirection).toBe("back"); }); }); diff --git a/src/hooks/useAppNavigate.ts b/src/hooks/useAppNavigate.ts index 9c89b5f6..c3496472 100644 --- a/src/hooks/useAppNavigate.ts +++ b/src/hooks/useAppNavigate.ts @@ -1,6 +1,8 @@ import { useCallback } from "react"; -import { useNavigate, type NavigateOptions, type To } from "react-router-dom"; -import { useMotionProfile } from "./useMotionProfile"; +import { type NavigateOptions, type To } from "react-router-dom"; +import type { BeginTransitionOptions } from "../navigation/routeTransitionContextInstance"; +import { useRouteTransition } from "../navigation/useRouteTransition"; +import { resolveNavigatePath } from "../navigation/routePreloaders"; const RESET_PATHS = new Set(["/", "/map"]); @@ -8,31 +10,22 @@ let navigationStack: string[] = [ typeof window !== "undefined" ? window.location.pathname : "/", ]; -function resolvePath(to: To): string { - if (typeof to === "string") { - return to.split("?")[0]?.split("#")[0] ?? ""; - } - - return to.pathname ?? ""; -} - /** @internal Test-only reset for navigation stack. */ export function resetAppNavigationStackForTests(path = "/"): void { navigationStack = [path]; } export function useAppNavigate() { - const navigate = useNavigate(); - const { animate } = useMotionProfile(); + const { beginTransition } = useRouteTransition(); return useCallback( ( to: To, options?: NavigateOptions & { - direction?: "forward" | "back" | "replace"; + direction?: BeginTransitionOptions["direction"]; }, ) => { - const path = resolvePath(to); + const path = resolveNavigatePath(to); const direction = options?.direction ?? (options?.replace ? "replace" : "forward"); @@ -44,25 +37,20 @@ export function useAppNavigate() { navigationStack.push(path); } - const navDir = - direction === "back" ? "back" : direction === "replace" ? "neutral" : "forward"; - document.documentElement.dataset.navDirection = navDir; - - navigate(to, { + void beginTransition(to, { replace: options?.replace, state: options?.state, preventScrollReset: options?.preventScrollReset, relative: options?.relative, - viewTransition: animate, + direction, }); }, - [navigate, animate], + [beginTransition], ); } export function useAppNavigationStack() { - const navigate = useNavigate(); - const { animate } = useMotionProfile(); + const { beginTransition } = useRouteTransition(); const canGoBack = useCallback(() => navigationStack.length > 1, []); @@ -73,9 +61,9 @@ export function useAppNavigationStack() { navigationStack.pop(); const destination = navigationStack[navigationStack.length - 1] ?? "/"; - document.documentElement.dataset.navDirection = "back"; - navigate(destination, { viewTransition: animate }); - }, [navigate, animate]); + + void beginTransition(destination, { direction: "back" }); + }, [beginTransition]); return { canGoBack, goBack }; } diff --git a/src/index.css b/src/index.css index e44062ba..48ca4251 100644 --- a/src/index.css +++ b/src/index.css @@ -9,4 +9,5 @@ @import "./styles/home-entry.css"; @import "./styles/leaflet-overrides.css"; @import "./styles/motion.css"; +@import "./styles/route-transition.css"; @import "./styles/motion-utilities.css"; diff --git a/src/navigation/AppNavigate.tsx b/src/navigation/AppNavigate.tsx new file mode 100644 index 00000000..fccebce8 --- /dev/null +++ b/src/navigation/AppNavigate.tsx @@ -0,0 +1,25 @@ +import { useEffect } from "react"; +import { useNavigate, type NavigateProps } from "react-router-dom"; +import { useRouteTransition } from "./useRouteTransition"; + +export function AppNavigate({ to, replace, state }: NavigateProps) { + const { phase, beginTransition } = useRouteTransition(); + const navigate = useNavigate(); + + useEffect(() => { + if (phase === "idle") { + void beginTransition(to, { + replace, + state, + direction: replace ? "replace" : "forward", + }); + return; + } + + navigate(to, { replace, state, viewTransition: false }); + // Redirect once on mount; during an active gate, follow the chain in-place. + // eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only redirect + }, []); + + return null; +} diff --git a/src/navigation/RouteReadinessSensor.tsx b/src/navigation/RouteReadinessSensor.tsx new file mode 100644 index 00000000..d2c8047d --- /dev/null +++ b/src/navigation/RouteReadinessSensor.tsx @@ -0,0 +1,16 @@ +import { useLayoutEffect } from "react"; +import { useLocation } from "react-router-dom"; +import { useRouteTransition } from "./useRouteTransition"; +import { useRouteScreenReady } from "./useRouteScreenReady"; + +export function RouteReadinessSensor() { + const { pathname } = useLocation(); + const screenReady = useRouteScreenReady(pathname); + const { reportScreenReady } = useRouteTransition(); + + useLayoutEffect(() => { + reportScreenReady(screenReady); + }, [screenReady, reportScreenReady]); + + return null; +} diff --git a/src/navigation/RouteTransitionContext.tsx b/src/navigation/RouteTransitionContext.tsx new file mode 100644 index 00000000..7e1f2e24 --- /dev/null +++ b/src/navigation/RouteTransitionContext.tsx @@ -0,0 +1,158 @@ +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; +import { + useLocation, + useNavigate, + type To, +} from "react-router-dom"; +import { useMotionProfile } from "../hooks/useMotionProfile"; +import { preloadRoute, resolveNavigatePath } from "./routePreloaders"; +import { + revealRouteTransition, + type NavRevealDirection, +} from "./revealRouteTransition"; +import { + RouteTransitionContext, + type BeginTransitionOptions, + type RouteTransitionPhase, +} from "./routeTransitionContextInstance"; + +export type { BeginTransitionOptions, RouteTransitionPhase }; + +const READY_POLL_MS = 16; +const READY_TIMEOUT_MS = 15_000; + +function toRevealDirection( + direction: BeginTransitionOptions["direction"], +): NavRevealDirection { + if (direction === "back") { + return "back"; + } + if (direction === "replace") { + return "neutral"; + } + return "forward"; +} + +function delay(ms: number): Promise { + return new Promise((resolve) => { + window.setTimeout(resolve, ms); + }); +} + +export function RouteTransitionProvider({ children }: { children: ReactNode }) { + const navigate = useNavigate(); + const location = useLocation(); + const { animate } = useMotionProfile(); + const [phase, setPhase] = useState("idle"); + + const phaseRef = useRef(phase); + const screenReadyRef = useRef(true); + const loadingTargetRef = useRef(null); + const pathnameRef = useRef(location.pathname); + + useEffect(() => { + phaseRef.current = phase; + }, [phase]); + + useEffect(() => { + pathnameRef.current = location.pathname; + }, [location.pathname]); + + const reportScreenReady = useCallback((ready: boolean) => { + screenReadyRef.current = ready; + }, []); + + const waitForScreenReady = useCallback(async (): Promise => { + const deadline = Date.now() + READY_TIMEOUT_MS; + let lastPathname = pathnameRef.current; + + while (Date.now() < deadline) { + const currentPathname = pathnameRef.current; + + if (currentPathname !== lastPathname) { + lastPathname = currentPathname; + screenReadyRef.current = false; + } + + if (screenReadyRef.current) { + return; + } + + await delay(READY_POLL_MS); + } + }, []); + + const beginTransition = useCallback( + async (to: To, options?: BeginTransitionOptions) => { + const targetPath = resolveNavigatePath(to); + + if ( + phaseRef.current === "loading" && + loadingTargetRef.current === targetPath + ) { + return; + } + + if (phaseRef.current === "loading") { + navigate(to, { + replace: options?.replace, + state: options?.state, + preventScrollReset: options?.preventScrollReset, + relative: options?.relative, + viewTransition: false, + }); + return; + } + + loadingTargetRef.current = targetPath; + screenReadyRef.current = false; + setPhase("loading"); + + try { + await preloadRoute(targetPath); + + navigate(to, { + replace: options?.replace, + state: options?.state, + preventScrollReset: options?.preventScrollReset, + relative: options?.relative, + viewTransition: false, + }); + + await waitForScreenReady(); + + setPhase("revealing"); + await revealRouteTransition( + toRevealDirection(options?.direction), + animate, + ); + } finally { + loadingTargetRef.current = null; + setPhase("idle"); + } + }, + [animate, navigate, waitForScreenReady], + ); + + const value = useMemo( + () => ({ + phase, + beginTransition, + reportScreenReady, + }), + [phase, beginTransition, reportScreenReady], + ); + + return ( + + {children} + + ); +} diff --git a/src/navigation/RouteTransitionOverlay.tsx b/src/navigation/RouteTransitionOverlay.tsx new file mode 100644 index 00000000..3a565fab --- /dev/null +++ b/src/navigation/RouteTransitionOverlay.tsx @@ -0,0 +1,24 @@ +import { LoadingSpinnerRing } from "../components/ui/LoadingSpinner"; +import { useRouteTransition } from "./useRouteTransition"; + +export function RouteTransitionOverlay() { + const { phase } = useRouteTransition(); + + if (phase !== "loading") { + return null; + } + + return ( +
+
+ + Loading… +
+
+ ); +} diff --git a/src/navigation/revealRouteTransition.ts b/src/navigation/revealRouteTransition.ts new file mode 100644 index 00000000..f7099581 --- /dev/null +++ b/src/navigation/revealRouteTransition.ts @@ -0,0 +1,16 @@ +export type NavRevealDirection = "forward" | "back" | "neutral"; + +export function revealRouteTransition( + direction: NavRevealDirection, + animate: boolean, +): Promise { + document.documentElement.dataset.navDirection = direction; + + if (!animate || typeof document.startViewTransition !== "function") { + return Promise.resolve(); + } + + return document.startViewTransition(() => { + // Destination is already mounted; capture the reveal frame. + }).finished.catch(() => undefined); +} diff --git a/src/navigation/routePreloaders.ts b/src/navigation/routePreloaders.ts new file mode 100644 index 00000000..eac00e28 --- /dev/null +++ b/src/navigation/routePreloaders.ts @@ -0,0 +1,61 @@ +import type { To } from "react-router-dom"; +import { lazyWithChunkRetry } from "../domain/device/lazyWithChunkRetry"; + +export const importMapScreen = () => + import("../routes/MapScreen").then((m) => ({ default: m.MapScreen })); + +export const importCreateSession = () => + import("../routes/CreateSession").then((m) => ({ default: m.CreateSession })); + +export const importGamePresetList = () => + import("../routes/GamePresets").then((m) => ({ default: m.GamePresetList })); + +export const importGamePresetEditor = () => + import("../routes/GamePresets").then((m) => ({ default: m.GamePresetEditor })); + +export const importTutorial = () => + import("../routes/Tutorial").then((m) => ({ default: m.Tutorial })); + +export const MapScreenLazy = lazyWithChunkRetry(importMapScreen); +export const CreateSessionLazy = lazyWithChunkRetry(importCreateSession); +export const GamePresetListLazy = lazyWithChunkRetry(importGamePresetList); +export const GamePresetEditorLazy = lazyWithChunkRetry(importGamePresetEditor); +export const TutorialLazy = lazyWithChunkRetry(importTutorial); + +const LAZY_ROUTE_LOADERS: Record Promise> = { + "/map": importMapScreen, + "/create": importCreateSession, + "/tutorial": importTutorial, + "/presets": importGamePresetList, + "/presets/new": importGamePresetEditor, + "/presets/:id/edit": importGamePresetEditor, +}; + +export function normalizeRoutePath(path: string): string { + const base = path.split("?")[0]?.split("#")[0] ?? "/"; + + if (/^\/presets\/[^/]+\/edit$/.test(base)) { + return "/presets/:id/edit"; + } + + return base || "/"; +} + +export function resolveNavigatePath(to: To): string { + if (typeof to === "string") { + return normalizeRoutePath(to); + } + + return normalizeRoutePath(to.pathname ?? "/"); +} + +export function isLazyRoute(path: string): boolean { + return normalizeRoutePath(path) in LAZY_ROUTE_LOADERS; +} + +export async function preloadRoute(path: string): Promise { + const loader = LAZY_ROUTE_LOADERS[normalizeRoutePath(path)]; + if (loader) { + await loader(); + } +} diff --git a/src/navigation/routeTransition.test.ts b/src/navigation/routeTransition.test.ts new file mode 100644 index 00000000..ee51f1b6 --- /dev/null +++ b/src/navigation/routeTransition.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import { + isLazyRoute, + normalizeRoutePath, + preloadRoute, +} from "./routePreloaders"; +import { routeReadinessKind } from "./useRouteScreenReady"; + +describe("normalizeRoutePath", () => { + it("strips query strings and hashes", () => { + expect(normalizeRoutePath("/map?foo=bar")).toBe("/map"); + expect(normalizeRoutePath("/create#step-2")).toBe("/create"); + }); + + it("normalizes preset edit paths", () => { + expect(normalizeRoutePath("/presets/abc123/edit")).toBe( + "/presets/:id/edit", + ); + expect(normalizeRoutePath("/presets/new")).toBe("/presets/new"); + }); + + it("defaults empty paths to root", () => { + expect(normalizeRoutePath("")).toBe("/"); + }); +}); + +describe("isLazyRoute", () => { + it("marks known lazy routes", () => { + expect(isLazyRoute("/map")).toBe(true); + expect(isLazyRoute("/create")).toBe(true); + expect(isLazyRoute("/tutorial")).toBe(true); + expect(isLazyRoute("/presets")).toBe(true); + expect(isLazyRoute("/presets/new")).toBe(true); + expect(isLazyRoute("/presets/foo/edit")).toBe(true); + }); + + it("marks eager routes as not lazy", () => { + expect(isLazyRoute("/")).toBe(false); + expect(isLazyRoute("/join")).toBe(false); + expect(isLazyRoute("/admin")).toBe(false); + expect(isLazyRoute("/premium")).toBe(false); + }); +}); + +describe("preloadRoute", () => { + it("resolves immediately for eager routes", async () => { + await expect(preloadRoute("/join")).resolves.toBeUndefined(); + }); + + it("loads lazy route modules without throwing", async () => { + await expect(preloadRoute("/tutorial")).resolves.toBeUndefined(); + }); +}); + +describe("routeReadinessKind", () => { + it("maps primary screens to readiness signals", () => { + expect(routeReadinessKind("/")).toBe("auth-bootstrap"); + expect(routeReadinessKind("/map")).toBe("play-area"); + expect(routeReadinessKind("/admin")).toBe("admin-auth"); + expect(routeReadinessKind("/premium")).toBe("premium"); + }); + + it("uses layout readiness for secondary routes", () => { + expect(routeReadinessKind("/join")).toBe("layout"); + expect(routeReadinessKind("/create")).toBe("layout"); + expect(routeReadinessKind("/tutorial")).toBe("layout"); + expect(routeReadinessKind("/presets")).toBe("layout"); + expect(routeReadinessKind("/feedback")).toBe("layout"); + }); +}); diff --git a/src/navigation/routeTransitionContextInstance.ts b/src/navigation/routeTransitionContextInstance.ts new file mode 100644 index 00000000..582e67f9 --- /dev/null +++ b/src/navigation/routeTransitionContextInstance.ts @@ -0,0 +1,17 @@ +import { createContext } from "react"; +import type { NavigateOptions, To } from "react-router-dom"; + +export type RouteTransitionPhase = "idle" | "loading" | "revealing"; + +export type BeginTransitionOptions = NavigateOptions & { + direction?: "forward" | "back" | "replace"; +}; + +export interface RouteTransitionContextValue { + phase: RouteTransitionPhase; + beginTransition: (to: To, options?: BeginTransitionOptions) => Promise; + reportScreenReady: (ready: boolean) => void; +} + +export const RouteTransitionContext = + createContext(null); diff --git a/src/navigation/useRouteScreenReady.ts b/src/navigation/useRouteScreenReady.ts new file mode 100644 index 00000000..6c51725c --- /dev/null +++ b/src/navigation/useRouteScreenReady.ts @@ -0,0 +1,48 @@ +import { usePremiumEntitlements } from "../hooks/billing/usePremiumEntitlements"; +import { usePermanentAuthUser } from "../hooks/billing/usePermanentAuthUser"; +import { useAuthBootstrapReady } from "../hooks/useAuthBootstrapReady"; +import { useResolvedSessionRules } from "../hooks/session/useResolvedSessionRules"; +import { useSessionStore } from "../state/sessionStore"; + +export type RouteReadinessKind = + | "auth-bootstrap" + | "play-area" + | "admin-auth" + | "premium" + | "layout"; + +export function routeReadinessKind(pathname: string): RouteReadinessKind { + switch (pathname) { + case "/": + return "auth-bootstrap"; + case "/map": + return "play-area"; + case "/admin": + return "admin-auth"; + case "/premium": + return "premium"; + default: + return "layout"; + } +} + +export function useRouteScreenReady(pathname: string): boolean { + const authBootstrapReady = useAuthBootstrapReady(); + const session = useSessionStore((state) => state.session); + const { playAreaReady } = useResolvedSessionRules(session); + const { authReady } = usePermanentAuthUser(); + const { loading: premiumLoading } = usePremiumEntitlements(); + + switch (routeReadinessKind(pathname)) { + case "auth-bootstrap": + return authBootstrapReady; + case "play-area": + return playAreaReady; + case "admin-auth": + return authReady; + case "premium": + return !premiumLoading; + case "layout": + return true; + } +} diff --git a/src/navigation/useRouteTransition.ts b/src/navigation/useRouteTransition.ts new file mode 100644 index 00000000..e4f18397 --- /dev/null +++ b/src/navigation/useRouteTransition.ts @@ -0,0 +1,13 @@ +import { useContext } from "react"; +import { + RouteTransitionContext, + type RouteTransitionContextValue, +} from "./routeTransitionContextInstance"; + +export function useRouteTransition(): RouteTransitionContextValue { + const context = useContext(RouteTransitionContext); + if (!context) { + throw new Error("useRouteTransition must be used within RouteTransitionProvider"); + } + return context; +} diff --git a/src/routes/AdminMapScreen.tsx b/src/routes/AdminMapScreen.tsx index a6c4f0d5..67577ad1 100644 --- a/src/routes/AdminMapScreen.tsx +++ b/src/routes/AdminMapScreen.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { Navigate } from "react-router-dom"; +import { AppNavigate } from "../navigation/AppNavigate"; import { GameAreaMask } from "../components/map/GameAreaMask"; import { MapView } from "../components/map/MapView"; import { MapViewportTracker } from "../components/map/MapViewportTracker"; @@ -109,11 +109,11 @@ export function AdminMapScreen({ }, [isWide, railCollapsed]); if (!controller.session) { - return ; + return ; } if (controller.myRole !== "admin") { - return ; + return ; } if (!controller.playAreaReady) { diff --git a/src/routes/AdminPanel.tsx b/src/routes/AdminPanel.tsx index b1db52d1..a0cb5abf 100644 --- a/src/routes/AdminPanel.tsx +++ b/src/routes/AdminPanel.tsx @@ -1,4 +1,4 @@ -import { Link } from "react-router-dom"; +import { AppLink } from "../components/navigation/AppLink"; import { useCallback, useMemo, useState } from "react"; import { signOut } from "firebase/auth"; import { PremiumSignInGate } from "../components/billing/PremiumSignInGate"; @@ -186,9 +186,9 @@ export function AdminPanel() { > Sign out - + Back home - + diff --git a/src/routes/CreateSession.test.tsx b/src/routes/CreateSession.test.tsx index 150ae213..48f8f376 100644 --- a/src/routes/CreateSession.test.tsx +++ b/src/routes/CreateSession.test.tsx @@ -100,13 +100,9 @@ vi.mock("../services/geo/seaLevelProgressive", () => ({ })); const navigate = vi.fn(); -vi.mock("react-router-dom", async () => { - const actual = await vi.importActual("react-router-dom"); - return { - ...actual, - useNavigate: () => navigate, - }; -}); +vi.mock("../hooks/useAppNavigate", () => ({ + useAppNavigate: () => navigate, +})); describe("CreateSession", () => { it("renders shape picker and fullscreen framing entry point", () => { @@ -125,7 +121,7 @@ describe("CreateSession", () => { fireEvent.click(screen.getByRole("button", { name: "Confirm game area" })); await waitFor(() => { - expect(navigate).toHaveBeenCalledWith("/map", { viewTransition: true }); + expect(navigate).toHaveBeenCalledWith("/map"); }); }); @@ -143,7 +139,7 @@ describe("CreateSession", () => { fireEvent.click(screen.getByRole("button", { name: "Confirm game area" })); await waitFor(() => { - expect(navigate).toHaveBeenCalledWith("/map", { viewTransition: true }); + expect(navigate).toHaveBeenCalledWith("/map"); }); }); diff --git a/src/routes/GamePresets.test.tsx b/src/routes/GamePresets.test.tsx index 873991e4..e47b34e3 100644 --- a/src/routes/GamePresets.test.tsx +++ b/src/routes/GamePresets.test.tsx @@ -8,13 +8,9 @@ import { mergeBundledPresets, BUNDLED_GAME_PRESET_DEFINITIONS } from "../domain/ const navigate = vi.fn(); -vi.mock("react-router-dom", async () => { - const actual = await vi.importActual("react-router-dom"); - return { - ...actual, - useNavigate: () => navigate, - }; -}); +vi.mock("../hooks/useAppNavigate", () => ({ + useAppNavigate: () => navigate, +})); vi.mock("../components/map/MapView", () => ({ MapView: () =>
, @@ -217,6 +213,6 @@ describe("GamePresetEditor", () => { .getState() .presets.find((preset) => preset.name === "Dublin medium")?.name, ).toBe("Dublin medium"); - expect(navigate).toHaveBeenCalledWith("/presets", { viewTransition: true }); + expect(navigate).toHaveBeenCalledWith("/presets"); }); }); diff --git a/src/routes/GamePresets.tsx b/src/routes/GamePresets.tsx index 84185fc4..489b34b6 100644 --- a/src/routes/GamePresets.tsx +++ b/src/routes/GamePresets.tsx @@ -1,5 +1,6 @@ import { useCallback, useId, useMemo, useState } from "react"; -import { Link, useParams } from "react-router-dom"; +import { AppLink } from "../components/navigation/AppLink"; +import { useParams } from "react-router-dom"; import { useAppNavigate } from "../hooks/useAppNavigate"; import type { LatLngBoundsExpression } from "leaflet"; import { @@ -311,9 +312,9 @@ export function GamePresetEditor() { Save preset {existing && !needsMigrationReview ? ( - + Host - + ) : null} {existing ? (
diff --git a/src/routes/HiderMapScreen.tsx b/src/routes/HiderMapScreen.tsx index 51b58166..2d713090 100644 --- a/src/routes/HiderMapScreen.tsx +++ b/src/routes/HiderMapScreen.tsx @@ -1,5 +1,5 @@ import { useCallback, useMemo, useRef, useState } from "react"; -import { Navigate } from "react-router-dom"; +import { AppNavigate } from "../navigation/AppNavigate"; import { Polygon } from "react-leaflet"; import { AnnotationLayer } from "../components/map/AnnotationLayer"; import { GameAreaMask } from "../components/map/GameAreaMask"; @@ -457,7 +457,7 @@ export function HiderMapScreen() { ); if (!session || !gameArea) { - return ; + return ; } const mapFocusBounds = gameAreaToBoundsExpression(gameArea); diff --git a/src/routes/Home.tsx b/src/routes/Home.tsx index 00e9df07..ab0ecd89 100644 --- a/src/routes/Home.tsx +++ b/src/routes/Home.tsx @@ -1,4 +1,4 @@ -import { Link } from "react-router-dom"; +import { AppLink } from "../components/navigation/AppLink"; import { useState } from "react"; import { AppLogo } from "../components/ui/AppLogo"; import { EntryScreenLayout } from "../components/ui/EntryScreenLayout"; @@ -23,6 +23,7 @@ import { isFirestorePermissionDenied } from "../services/firestore/firestoreAnno import { useSessionExit } from "../hooks/session/useSessionExit"; import { setPremiumApiContext } from "../services/core/premiumApiContext"; import { useAppNavigate } from "../hooks/useAppNavigate"; +import { useRouteTransition } from "../navigation/useRouteTransition"; import { usePremiumEntitlements } from "../hooks/billing/usePremiumEntitlements"; import { resolveHomePremiumButtonDisplay } from "../domain/billing/premiumProducts"; import { useAuthBootstrapReady } from "../hooks/useAuthBootstrapReady"; @@ -44,10 +45,15 @@ export function Home() { const { user: permanentUser } = usePermanentAuthUser(); const showAdminEntry = isAdminUser(permanentUser); const authBootstrapReady = useAuthBootstrapReady(); + const { phase: routeTransitionPhase } = useRouteTransition(); const premiumButton = resolveHomePremiumButtonDisplay(premiumEntitlements); - if (isFirebaseConfigured() && !authBootstrapReady) { + if ( + isFirebaseConfigured() && + !authBootstrapReady && + routeTransitionPhase === "idle" + ) { return (
{showAdminEntry ? ( - - + ) : null} - - + setChangelogOpen(true)} @@ -245,7 +251,7 @@ export function Home() { ) : null} - Create session Host a game - - + + Join session Enter 4-letter code - - + Custom game Saved templates - + {isFirebaseConfigured() ? ( - {premiumButton.detailLabel} - + ) : null} {continueError ? {continueError} : null}
diff --git a/src/routes/MapScreen.test.tsx b/src/routes/MapScreen.test.tsx index d9edbfb0..1ef87609 100644 --- a/src/routes/MapScreen.test.tsx +++ b/src/routes/MapScreen.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, screen } from "@testing-library/react"; +import { fireEvent, screen, waitFor } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import { Route, Routes } from "react-router-dom"; import { MapScreen } from "./MapScreen"; @@ -100,7 +100,7 @@ vi.mock("../services/core/firebase", () => ({ })); describe("MapScreen", () => { - it("redirects to create when no session game area exists", () => { + it("redirects to create when no session game area exists", async () => { useSessionStore.getState().setSession( createTestSession({ gameArea: undefined }), ); @@ -113,7 +113,9 @@ describe("MapScreen", () => { { route: "/map", resetStores: false }, ); - expect(screen.getByText("Create session landing")).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByText("Create session landing")).toBeInTheDocument(); + }); }); it("renders the tool dock for an active session", () => { diff --git a/src/routes/MapScreen.tsx b/src/routes/MapScreen.tsx index 31858239..86f9f8b8 100644 --- a/src/routes/MapScreen.tsx +++ b/src/routes/MapScreen.tsx @@ -1,5 +1,5 @@ import { useEffect, type ReactNode } from "react"; -import { Navigate } from "react-router-dom"; +import { AppNavigate } from "../navigation/AppNavigate"; import { HiderMapScreen } from "./HiderMapScreen"; import { AdminMapScreen } from "./AdminMapScreen"; import { ObserverMapScreen } from "./ObserverMapScreen"; @@ -25,7 +25,7 @@ export function MapScreen() { useEffect(() => () => teardownSessionUiState(), []); if (!session || !session.gameArea) { - return ; + return ; } if (myRole === "hider") { diff --git a/src/routes/ObserverMapScreen.tsx b/src/routes/ObserverMapScreen.tsx index 55d66cbc..206e7612 100644 --- a/src/routes/ObserverMapScreen.tsx +++ b/src/routes/ObserverMapScreen.tsx @@ -1,5 +1,5 @@ import { useCallback } from "react"; -import { Navigate } from "react-router-dom"; +import { AppNavigate } from "../navigation/AppNavigate"; import { GameAreaMask } from "../components/map/GameAreaMask"; import { MapView } from "../components/map/MapView"; import { MapViewportTracker } from "../components/map/MapViewportTracker"; @@ -41,12 +41,12 @@ export function ObserverMapScreen() { if (!controller.session) { return ( - + ); } if (controller.myRole !== "observer" && controller.myRole !== "admin") { - return ; + return ; } if (!controller.playAreaReady) { diff --git a/src/routes/create-session/PremiumGateSection.tsx b/src/routes/create-session/PremiumGateSection.tsx index ac2cda6c..0cb76b93 100644 --- a/src/routes/create-session/PremiumGateSection.tsx +++ b/src/routes/create-session/PremiumGateSection.tsx @@ -1,4 +1,4 @@ -import { Link } from "react-router-dom"; +import { AppLink } from "../../components/navigation/AppLink"; import { PremiumSignInGate } from "../../components/billing/PremiumSignInGate"; export interface PremiumGateSectionProps { @@ -44,12 +44,12 @@ export function PremiumGateSection({

Buy a session pack or subscription to host premium games.

- View premium options - + {myRole === "admin" ? ( - Admin - + ) : null}
diff --git a/src/styles/route-transition.css b/src/styles/route-transition.css new file mode 100644 index 00000000..88d0dc86 --- /dev/null +++ b/src/styles/route-transition.css @@ -0,0 +1,78 @@ +.route-transition-overlay { + position: fixed; + inset: 0; + z-index: var(--z-modal); + display: flex; + align-items: center; + justify-content: center; + background: var(--color-surface-deep, rgb(12 14 18 / 0.92)); + animation: route-transition-overlay-enter var(--motion-fast, 200ms) + var(--ease-out-quint, cubic-bezier(0.22, 1, 0.36, 1)) both; +} + +.route-transition-overlay-content { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.75rem; +} + +.route-transition-overlay-label { + font-family: var(--font-display, inherit); + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--color-ink-secondary, currentColor); +} + +@keyframes route-transition-overlay-enter { + from { + opacity: 0; + transform: scale(0.98); + } + to { + opacity: 1; + transform: scale(1); + } +} + +@keyframes route-transition-overlay-exit { + from { + opacity: 1; + transform: scale(1); + } + to { + opacity: 0; + transform: scale(0.98); + } +} + +@media (prefers-reduced-motion: reduce) { + .route-transition-overlay { + animation: route-transition-overlay-fade-in var(--motion-fast, 200ms) ease + both; + } + + .route-transition-overlay .loading-spinner { + animation: none !important; + } +} + +html[data-motion="reduce"] .route-transition-overlay { + animation: route-transition-overlay-fade-in var(--motion-fast, 200ms) ease + both; +} + +html[data-motion="reduce"] .route-transition-overlay .loading-spinner { + animation: none !important; +} + +@keyframes route-transition-overlay-fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} diff --git a/src/test/RouteTransitionTestProvider.tsx b/src/test/RouteTransitionTestProvider.tsx new file mode 100644 index 00000000..bcffa068 --- /dev/null +++ b/src/test/RouteTransitionTestProvider.tsx @@ -0,0 +1,51 @@ +import { useCallback, useMemo, useRef, type ReactNode } from "react"; +import { useNavigate, type To } from "react-router-dom"; +import type { BeginTransitionOptions, RouteTransitionPhase } from "../navigation/routeTransitionContextInstance"; +import { RouteTransitionContext } from "../navigation/routeTransitionContextInstance"; +import { preloadRoute, resolveNavigatePath } from "../navigation/routePreloaders"; +import { revealRouteTransition } from "../navigation/revealRouteTransition"; + +/** Fast path for unit tests: skip readiness polling and motion. */ +export function RouteTransitionTestProvider({ children }: { children: ReactNode }) { + const navigate = useNavigate(); + const screenReadyRef = useRef(true); + + const beginTransition = useCallback( + async (to: To, options?: BeginTransitionOptions) => { + screenReadyRef.current = true; + await preloadRoute(resolveNavigatePath(to)); + navigate(to, { + replace: options?.replace, + state: options?.state, + preventScrollReset: options?.preventScrollReset, + relative: options?.relative, + viewTransition: false, + }); + const direction = + options?.direction === "back" + ? "back" + : options?.direction === "replace" + ? "neutral" + : "forward"; + await revealRouteTransition(direction, false); + }, + [navigate], + ); + + const value = useMemo( + () => ({ + phase: "idle" as RouteTransitionPhase, + beginTransition, + reportScreenReady: (ready: boolean) => { + screenReadyRef.current = ready; + }, + }), + [beginTransition], + ); + + return ( + + {children} + + ); +} diff --git a/src/test/renderWithRouter.tsx b/src/test/renderWithRouter.tsx index 74f77d1c..ca8fd1f9 100644 --- a/src/test/renderWithRouter.tsx +++ b/src/test/renderWithRouter.tsx @@ -1,6 +1,7 @@ import { render, type RenderOptions } from "@testing-library/react"; import { type ReactElement, type ReactNode } from "react"; import { MemoryRouter, type MemoryRouterProps } from "react-router-dom"; +import { RouteTransitionTestProvider } from "./RouteTransitionTestProvider"; import { resetAllStores } from "./helpers/storeReset"; interface RenderWithRouterOptions extends Omit { @@ -27,7 +28,7 @@ export function renderWithRouter( function Wrapper({ children }: { children: ReactNode }) { return ( - {children} + {children} ); }