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
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,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.
Expand Down
183 changes: 90 additions & 93 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@ import { Suspense, useEffect, type ReactNode } from "react";
import * as Sentry from "@sentry/react";
import {
BrowserRouter,
Link,
Navigate,
Route,
Routes,
useLocation,
Expand All @@ -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";
Expand All @@ -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 (
Expand Down Expand Up @@ -130,12 +123,12 @@ function AppErrorFallback() {
>
Reload
</button>
<Link
<AppLink
to="/"
className="btn-secondary border border-border px-4 py-2 text-sm"
>
Back home
</Link>
</AppLink>
</div>
</div>
);
Expand All @@ -159,78 +152,82 @@ export default function App() {

return (
<BrowserRouter>
<AppUpdateProvider>
<Sentry.ErrorBoundary fallback={<AppErrorFallback />}>
<MotionDatasetEffect />
<EdgeSwipeBackBinder />
<AnalyticsPageViewTracker />
<ChunkReloadContextBinder />
<AppUpdateBanner />
<div className="h-[100dvh] overflow-y-auto overscroll-y-none">
<LowBatteryPrompt />
<Routes>
<Route path="/" element={<Home />} />
<Route
path="/tutorial"
element={
<LazyRoute>
<Tutorial />
</LazyRoute>
}
/>
<Route path="/feedback" element={<Feedback />} />
<Route path="/privacy" element={<Privacy />} />
<Route path="/terms" element={<Terms />} />
<Route path="/premium" element={<Premium />} />
<Route
path="/create"
element={
<LazyRoute>
<CreateSession />
</LazyRoute>
}
/>
<Route path="/join" element={<JoinSession />} />
<Route path="/admin" element={<AdminPanel />} />
<Route
path="/presets"
element={
<LazyRoute>
<GamePresetList />
</LazyRoute>
}
/>
<Route
path="/presets/new"
element={
<LazyRoute>
<GamePresetEditor />
</LazyRoute>
}
/>
<Route
path="/presets/:id/edit"
element={
<LazyRoute>
<GamePresetEditor />
</LazyRoute>
}
/>
<Route
path="/map"
element={
<LazyRoute>
<MapErrorBoundary>
<MapScreen />
</MapErrorBoundary>
</LazyRoute>
}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</div>
</Sentry.ErrorBoundary>
</AppUpdateProvider>
<RouteTransitionProvider>
<AppUpdateProvider>
<Sentry.ErrorBoundary fallback={<AppErrorFallback />}>
<MotionDatasetEffect />
<RouteTransitionOverlay />
<RouteReadinessSensor />
<EdgeSwipeBackBinder />
<AnalyticsPageViewTracker />
<ChunkReloadContextBinder />
<AppUpdateBanner />
<div className="h-[100dvh] overflow-y-auto overscroll-y-none">
<LowBatteryPrompt />
<Routes>
<Route path="/" element={<Home />} />
<Route
path="/tutorial"
element={
<LazyRoute>
<TutorialLazy />
</LazyRoute>
}
/>
<Route path="/feedback" element={<Feedback />} />
<Route path="/privacy" element={<Privacy />} />
<Route path="/terms" element={<Terms />} />
<Route path="/premium" element={<Premium />} />
<Route
path="/create"
element={
<LazyRoute>
<CreateSessionLazy />
</LazyRoute>
}
/>
<Route path="/join" element={<JoinSession />} />
<Route path="/admin" element={<AdminPanel />} />
<Route
path="/presets"
element={
<LazyRoute>
<GamePresetListLazy />
</LazyRoute>
}
/>
<Route
path="/presets/new"
element={
<LazyRoute>
<GamePresetEditorLazy />
</LazyRoute>
}
/>
<Route
path="/presets/:id/edit"
element={
<LazyRoute>
<GamePresetEditorLazy />
</LazyRoute>
}
/>
<Route
path="/map"
element={
<LazyRoute>
<MapErrorBoundary>
<MapScreenLazy />
</MapErrorBoundary>
</LazyRoute>
}
/>
<Route path="*" element={<AppNavigate to="/" replace />} />
</Routes>
</div>
</Sentry.ErrorBoundary>
</AppUpdateProvider>
</RouteTransitionProvider>
</BrowserRouter>
);
}
6 changes: 3 additions & 3 deletions src/components/billing/PremiumTierCards.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -191,14 +191,14 @@ export function PremiumTierCards({
) : null}

{entitlements?.canCreatePremium ? (
<Link
<AppLink
to="/create?tier=premium"
className={resolveCreatePremiumButtonClass(entitlements)}
aria-label="Create premium session"
>
<span>Create premium session</span>
<span className="home-card-btn-hint">{createSessionHint}</span>
</Link>
</AppLink>
) : null}
</div>
</>
Expand Down
6 changes: 3 additions & 3 deletions src/components/legal/LegalDocumentPage.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Link } from "react-router-dom";
import { AppLink } from "../navigation/AppLink";
import { EntryScreenLayout } from "../ui/EntryScreenLayout";
import {
ScreenHeader,
Expand Down Expand Up @@ -77,12 +77,12 @@ export function LegalDocumentPage({
</p>
<p className="text-sm text-ink-muted">
See also{" "}
<Link
<AppLink
to={otherPath}
className="text-highlight underline-offset-2 hover:underline"
>
{otherLabel}
</Link>
</AppLink>
.
</p>
</footer>
Expand Down
10 changes: 5 additions & 5 deletions src/components/legal/LegalInlineLinks.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<p className="text-pretty text-sm leading-relaxed text-ink-muted">
See{" "}
<Link
<AppLink
to={LEGAL_TERMS_PATH}
className="text-ink-secondary underline-offset-2 hover:text-highlight hover:underline"
>
Terms
</Link>{" "}
</AppLink>{" "}
and{" "}
<Link
<AppLink
to={LEGAL_PRIVACY_PATH}
className="text-ink-secondary underline-offset-2 hover:text-highlight hover:underline"
>
Privacy
</Link>
</AppLink>
.
</p>
);
Expand Down
83 changes: 83 additions & 0 deletions src/components/navigation/AppLink.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import {
Link,
useNavigate,
type LinkProps,
} from "react-router-dom";
import { useRouteTransition } from "../../navigation/useRouteTransition";

export function AppLink({
to,
onClick,
target,
replace,
state,
preventScrollReset,
relative,
reloadDocument,
...props
}: LinkProps) {
const { beginTransition } = useRouteTransition();
const navigate = useNavigate();

const bypassTransition =
reloadDocument === true || (target !== undefined && target !== "_self");

if (bypassTransition) {
return (
<Link
{...props}
to={to}
target={target}
replace={replace}
state={state}
preventScrollReset={preventScrollReset}
relative={relative}
reloadDocument={reloadDocument}
onClick={onClick}
/>
);
}
Comment on lines +22 to +39

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Dead _blank check inside the click handler.

bypassTransition already returns early for any target other than undefined/"_self" (Line 23), so by the time execution reaches Line 72 target can only be undefined or "_self". The if (target === "_blank") guard can never be true here and is dead code that may mislead future edits into thinking _blank is handled at this site.

♻️ Proposed cleanup
         if (target === "_blank") {
           return;
         }

         event.preventDefault();

becomes

         event.preventDefault();

Other than this, the previously flagged issues (LinkProps passthrough and unhandled beginTransition rejection) look correctly addressed.

Also applies to: 72-74

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/navigation/AppLink.tsx` around lines 22 - 39, Remove the
unreachable target === "_blank" branch from the click handler in AppLink, since
bypassTransition already returns for non-undefined, non-"_self" targets.
Preserve the remaining click handling for targets that reach that path.


const transitionOptions = {
replace,
state,
preventScrollReset,
relative,
};

return (
<Link
{...props}
to={to}
target={target}
replace={replace}
state={state}
preventScrollReset={preventScrollReset}
relative={relative}
viewTransition={false}
onClick={(event) => {
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, transitionOptions).catch(() => {
navigate(to, { ...transitionOptions, viewTransition: false });
});
}}
/>
);
}
Loading
Loading