perf(client): pick the viewport branch at the route (#1817)

Every page imported its mobile screen statically and chose while rendering, so
each route chunk carried both trees and the viewport only decided which half
stayed dark. Worse, because ten route chunks shared the mobile tree, rollup
hoisted it into the entry chunk — so every desktop user downloaded the whole
mobile shell before seeing a single page.

The branch now decides the chunk. A phone never fetches the desktop planner, a
desktop never the mobile shell.

  entry chunk        1,243 -> 581 kB   (gzip 349 -> 177)
  TripPlannerPage      914 -> 340 kB
  VacayPage            361 ->  57 kB
  AdminPage            413 -> 225 kB
  SettingsPage         259 -> 155 kB

Splitting inside each page instead was measured and rejected: it leaves the
desktop code in the same module as the switch, so a phone would still download
it and then wait for a second, serial request on top — 914 kB and a waterfall
against 550 kB in parallel.

The pass-through wrapper in each page stays deliberately. check-page-pattern
scans from the default export to the next top-level function and rejects hooks
there; making the desktop component the default export fails it immediately on
dashboard, planner and atlas.

RouteFallback gets a mobile face. It renders above MobileShell, so the --m-*
tokens do not resolve for it and the desktop spinner on bg-surface would be a
foreign white sheet in front of the mobile screen.

The five scattered viewport spot checks are replaced by one contract covering
all eleven routes, which is where the switch now lives.
This commit is contained in:
Maurice
2026-08-07 11:51:37 +02:00
committed by GitHub
parent 1d94814b57
commit 057d4ba503
18 changed files with 209 additions and 133 deletions
+48 -11
View File
@@ -14,6 +14,7 @@ import SaveToCollectionModal from './components/Collections/SaveToCollectionModa
import MSaveToCollectionSheet from './components/Collections/MSaveToCollectionSheet'
import BackgroundTasksWidget from './components/BackgroundTasks/BackgroundTasksWidget'
import MobileShell from './mobile/MobileShell'
import MRouteFallback from './mobile/components/MRouteFallback'
import ErrorBoundary from './components/shared/ErrorBoundary'
import { lazyWithRetry } from './utils/lazyWithRetry'
import { useIsPhone } from './mobile/useIsPhone'
@@ -52,6 +53,23 @@ const JoinTripPage = lazyWithRetry(() => import('./pages/JoinTripPage'))
const InAppNotificationsPage = lazyWithRetry(() => import('./pages/InAppNotificationsPage.tsx'))
const OAuthAuthorizePage = lazyWithRetry(() => import('./pages/OAuthAuthorizePage'))
// The ten phone screens are chunks of their own, alongside the desktop pages
// rather than inside them. Each page used to import its M screen statically and
// decide while rendering, so the route chunk always carried both trees and the
// viewport only picked which half stayed dark. Here the branch decides the
// chunk: a phone never loads the desktop planner, a desktop never the mobile
// shell.
const MDashboardScreen = lazyWithRetry(() => import('./mobile/screens/dashboard/MDashboard'))
const MTripScreen = lazyWithRetry(() => import('./mobile/screens/trip/MTripShell'))
const MAdminScreen = lazyWithRetry(() => import('./mobile/screens/admin/MAdmin'))
const MSettingsScreen = lazyWithRetry(() => import('./mobile/screens/settings/MSettings'))
const MVacayScreen = lazyWithRetry(() => import('./mobile/screens/vacay/MVacay'))
const MAtlasScreen = lazyWithRetry(() => import('./mobile/screens/atlas/MAtlas'))
const MJourneyScreen = lazyWithRetry(() => import('./mobile/screens/journey/MJourney'))
const MJourneyDetailScreen = lazyWithRetry(() => import('./mobile/screens/journey/MJourneyDetail'))
const MCollectionsScreen = lazyWithRetry(() => import('./mobile/screens/collections/MCollections'))
const MNotificationsScreen = lazyWithRetry(() => import('./mobile/screens/notifications/MNotifications'))
interface ProtectedRouteProps {
children: ReactNode
adminRequired?: boolean
@@ -140,6 +158,20 @@ function PublicRoute({ children }: { children: React.ReactNode }) {
)
}
/**
* Picks the chunk, not just the branch. Both sides come through lazyWithRetry,
* so exactly one of them is fetched for a given viewport — which is the whole
* point: the ternary that used to sit in each page only ran once the browser
* had already paid for both trees.
*/
function ViewportRoute({ phone: Phone, desktop: Desktop }: {
phone: React.ComponentType
desktop: React.ComponentType
}): React.ReactElement {
const isPhone = useIsPhone()
return isPhone ? <Phone /> : <Desktop />
}
function RootRedirect() {
const { isAuthenticated, isLoading } = useAuthStore()
@@ -160,6 +192,11 @@ function RootRedirect() {
* get to inherit its raw slate.
*/
function RouteFallback() {
// The fallback renders above MobileShell, so it has to pick its own palette —
// on a phone the desktop spinner on bg-surface would be a foreign white sheet
// in front of the mobile screen.
const isPhone = useIsPhone()
if (isPhone) return <MRouteFallback />
return (
<div className="min-h-screen flex items-center justify-center bg-surface">
<div className="w-10 h-10 border-4 border-edge border-t-content rounded-full animate-spin"></div>
@@ -290,7 +327,7 @@ export default function App() {
path="/dashboard"
element={
<ProtectedRoute>
<DashboardPage />
<ViewportRoute phone={MDashboardScreen} desktop={DashboardPage} />
</ProtectedRoute>
}
/>
@@ -324,7 +361,7 @@ export default function App() {
path="/trips/:id"
element={
<ProtectedRoute>
<TripPlannerPage />
<ViewportRoute phone={MTripScreen} desktop={TripPlannerPage} />
</ProtectedRoute>
}
/>
@@ -340,7 +377,7 @@ export default function App() {
path="/admin"
element={
<ProtectedRoute adminRequired>
<AdminPage />
<ViewportRoute phone={MAdminScreen} desktop={AdminPage} />
</ProtectedRoute>
}
/>
@@ -348,7 +385,7 @@ export default function App() {
path="/settings"
element={
<ProtectedRoute>
<SettingsPage />
<ViewportRoute phone={MSettingsScreen} desktop={SettingsPage} />
</ProtectedRoute>
}
/>
@@ -364,7 +401,7 @@ export default function App() {
path="/vacay"
element={
<ProtectedRoute>
<VacayPage />
<ViewportRoute phone={MVacayScreen} desktop={VacayPage} />
</ProtectedRoute>
}
/>
@@ -372,7 +409,7 @@ export default function App() {
path="/atlas"
element={
<ProtectedRoute>
<AtlasPage />
<ViewportRoute phone={MAtlasScreen} desktop={AtlasPage} />
</ProtectedRoute>
}
/>
@@ -380,7 +417,7 @@ export default function App() {
path="/journey"
element={
<ProtectedRoute addonId="journey">
<JourneyPage />
<ViewportRoute phone={MJourneyScreen} desktop={JourneyPage} />
</ProtectedRoute>
}
/>
@@ -388,7 +425,7 @@ export default function App() {
path="/journey/:id"
element={
<ProtectedRoute addonId="journey">
<JourneyDetailPage />
<ViewportRoute phone={MJourneyDetailScreen} desktop={JourneyDetailPage} />
</ProtectedRoute>
}
/>
@@ -396,7 +433,7 @@ export default function App() {
path="/collections"
element={
<ProtectedRoute addonId="collections">
<CollectionsPage />
<ViewportRoute phone={MCollectionsScreen} desktop={CollectionsPage} />
</ProtectedRoute>
}
/>
@@ -404,7 +441,7 @@ export default function App() {
path="/collections/:id"
element={
<ProtectedRoute addonId="collections">
<CollectionsPage />
<ViewportRoute phone={MCollectionsScreen} desktop={CollectionsPage} />
</ProtectedRoute>
}
/>
@@ -412,7 +449,7 @@ export default function App() {
path="/notifications"
element={
<ProtectedRoute>
<InAppNotificationsPage />
<ViewportRoute phone={MNotificationsScreen} desktop={InAppNotificationsPage} />
</ProtectedRoute>
}
/>
+111
View File
@@ -0,0 +1,111 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
import { MemoryRouter } from 'react-router'
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { useAuthStore } from './store/authStore'
import { useAddonStore } from './store/addonStore'
import { resetAllStores } from '../tests/helpers/store'
import { buildUser } from '../tests/helpers/factories'
import App from './App'
/**
* The viewport decides which chunk gets fetched, so the switch lives in App.tsx
* and no longer in the pages. This is that contract, for all ten routes in one
* place — it used to be five scattered spot checks, one per page test.
*
* A stub per screen keeps the test about one thing: below the breakpoint the M
* root hangs off the route, and the desktop page is never rendered at all.
*/
const isPhone = vi.hoisted(() => ({ value: false }))
vi.mock('./mobile/useIsPhone', () => ({ useIsPhone: () => isPhone.value }))
vi.mock('./mobile/screens/dashboard/MDashboard', () => ({ default: () => <div>m-dashboard</div> }))
vi.mock('./mobile/screens/trip/MTripShell', () => ({ default: () => <div>m-trip</div> }))
vi.mock('./mobile/screens/admin/MAdmin', () => ({ default: () => <div>m-admin</div> }))
vi.mock('./mobile/screens/settings/MSettings', () => ({ default: () => <div>m-settings</div> }))
vi.mock('./mobile/screens/vacay/MVacay', () => ({ default: () => <div>m-vacay</div> }))
vi.mock('./mobile/screens/atlas/MAtlas', () => ({ default: () => <div>m-atlas</div> }))
vi.mock('./mobile/screens/journey/MJourney', () => ({ default: () => <div>m-journey</div> }))
vi.mock('./mobile/screens/journey/MJourneyDetail', () => ({ default: () => <div>m-journey-detail</div> }))
vi.mock('./mobile/screens/collections/MCollections', () => ({ default: () => <div>m-collections</div> }))
vi.mock('./mobile/screens/notifications/MNotifications', () => ({ default: () => <div>m-notifications</div> }))
vi.mock('./pages/DashboardPage', () => ({ default: () => <div>d-dashboard</div> }))
vi.mock('./pages/TripPlannerPage', () => ({ default: () => <div>d-trip</div> }))
vi.mock('./pages/AdminPage', () => ({ default: () => <div>d-admin</div> }))
vi.mock('./pages/SettingsPage', () => ({ default: () => <div>d-settings</div> }))
vi.mock('./pages/VacayPage', () => ({ default: () => <div>d-vacay</div> }))
vi.mock('./pages/AtlasPage', () => ({ default: () => <div>d-atlas</div> }))
vi.mock('./pages/JourneyPage', () => ({ default: () => <div>d-journey</div> }))
vi.mock('./pages/JourneyDetailPage', () => ({ default: () => <div>d-journey-detail</div> }))
vi.mock('./pages/CollectionsPage', () => ({ default: () => <div>d-collections</div> }))
vi.mock('./pages/InAppNotificationsPage.tsx', () => ({ default: () => <div>d-notifications</div> }))
// The notification listener opens a WebSocket on mount.
vi.mock('./hooks/useInAppNotificationListener.ts', () => ({
useInAppNotificationListener: vi.fn(),
}))
/** path, mobile marker, desktop marker */
const ROUTES: [string, string, string][] = [
['/dashboard', 'm-dashboard', 'd-dashboard'],
['/trips/1', 'm-trip', 'd-trip'],
['/admin', 'm-admin', 'd-admin'],
['/settings', 'm-settings', 'd-settings'],
['/vacay', 'm-vacay', 'd-vacay'],
['/atlas', 'm-atlas', 'd-atlas'],
['/journey', 'm-journey', 'd-journey'],
['/journey/1', 'm-journey-detail', 'd-journey-detail'],
['/collections', 'm-collections', 'd-collections'],
['/collections/1', 'm-collections', 'd-collections'],
['/notifications', 'm-notifications', 'd-notifications'],
]
function renderAt(path: string) {
return render(
<MemoryRouter initialEntries={[path]}>
<App />
</MemoryRouter>
)
}
beforeEach(() => {
resetAllStores()
vi.clearAllMocks()
isPhone.value = false
// An admin passes the role check on /admin; every other route ignores it.
useAuthStore.setState({
isLoading: false,
isAuthenticated: true,
user: buildUser({ role: 'admin', mfa_enabled: true }),
appRequireMfa: false,
loadUser: vi.fn().mockResolvedValue(undefined),
})
// /journey and /collections sit behind addons; without this they redirect to
// the dashboard and the route under test never renders.
useAddonStore.setState({ loaded: true, isEnabled: () => true })
})
describe('App — viewport routing', () => {
it.each(ROUTES)(
'FE-COMP-APPVP-001: %s renders the mobile screen below the breakpoint',
async (path, mobile, desktop) => {
isPhone.value = true
renderAt(path)
expect(await screen.findByText(mobile)).toBeInTheDocument()
expect(screen.queryByText(desktop)).not.toBeInTheDocument()
}
)
it.each(ROUTES)(
'FE-COMP-APPVP-002: %s renders the desktop page above the breakpoint',
async (path, mobile, desktop) => {
renderAt(path)
expect(await screen.findByText(desktop)).toBeInTheDocument()
expect(screen.queryByText(mobile)).not.toBeInTheDocument()
}
)
})
@@ -0,0 +1,19 @@
/**
* Placeholder while a route chunk is in flight — the phone counterpart to
* RouteFallback in App.tsx. It carries m-root itself, because it renders outside
* MobileShell and the --m-* tokens would not resolve otherwise.
*
* An outline rather than a spinner: on a phone an empty screen is the loudest
* state the app has, and a pulsing silhouette of what is about to arrive reads
* as "loading" instead of "broken".
*/
export default function MRouteFallback() {
return (
<div className="m-root flex h-dvh flex-col gap-3 px-4 pt-14 bg-[color:var(--m-bg)]">
<div className="h-7 w-40 animate-pulse rounded-lg bg-m-card" />
<div className="h-28 animate-pulse rounded-xl bg-m-card" />
<div className="h-28 animate-pulse rounded-xl bg-m-card" />
<div className="h-28 animate-pulse rounded-xl bg-m-card" />
</div>
)
}
-23
View File
@@ -69,19 +69,8 @@ vi.mock('../components/Admin/DefaultUserSettingsTab', () => ({
default: () => <div data-testid="default-user-settings" />,
}));
vi.mock('../mobile/screens/admin/MAdmin', () => ({
default: () => <div data-testid="mobile-admin" />,
}));
// Viewport switch — flipped per test instead of at module scope.
let phoneViewport = false;
vi.mock('../mobile/useIsPhone', () => ({
useIsPhone: () => phoneViewport,
}));
beforeEach(() => {
resetAllStores();
phoneViewport = false;
});
describe('AdminPage', () => {
@@ -1617,18 +1606,6 @@ describe('AdminPage', () => {
});
});
describe('FE-PAGE-ADMIN-058: Phone viewport', () => {
it('renders the mobile admin screen instead of the desktop layout', async () => {
phoneViewport = true;
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
render(<AdminPage />);
expect(screen.getByTestId('mobile-admin')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /^users$/i })).not.toBeInTheDocument();
});
});
describe('FE-PAGE-ADMIN-059: Stats fallbacks', () => {
it('shows a zero file count when the API omits totalFiles', async () => {
server.use(
+3 -4
View File
@@ -14,8 +14,6 @@ import AdminMcpTokensPanel from '../components/Admin/AdminMcpTokensPanel'
import AdminPluginsPanel from '../components/Admin/AdminPluginsPanel'
import { Users, Map, Briefcase, Shield, FileText, SlidersHorizontal, UserCog, Puzzle, Blocks, Settings as SettingsIcon, Bell, Database, ScrollText, KeyRound, GitBranch, Bug } from 'lucide-react'
import PageSidebar, { type PageSidebarTab } from '../components/Layout/PageSidebar'
import { useIsPhone } from '../mobile/useIsPhone'
import MAdmin from '../mobile/screens/admin/MAdmin'
import { useAdmin } from './admin/useAdmin'
import AdminUpdateBanner from './admin/AdminUpdateBanner'
import AdminStatCard from './admin/AdminStatCard'
@@ -25,8 +23,9 @@ import AdminNotificationsTab from './admin/AdminNotificationsTab'
import AdminUserModals from './admin/AdminUserModals'
export default function AdminPage(): React.ReactElement {
const isPhone = useIsPhone()
return isPhone ? <MAdmin /> : <AdminPageDesktop />
// ViewportRoute in App.tsx picks the branch now, so the phone screen is a
// chunk of its own instead of a dead limb in this one.
return <AdminPageDesktop />
}
function AdminPageDesktop(): React.ReactElement {
+3 -4
View File
@@ -13,12 +13,11 @@ import AtlasCountrySearch from './atlas/AtlasCountrySearch'
import AtlasLayerToggle from './atlas/AtlasLayerToggle'
import { useToast } from '../components/shared/Toast'
import { getApiErrorMessage } from '../types'
import { useIsPhone } from '../mobile/useIsPhone'
import MAtlas from '../mobile/screens/atlas/MAtlas'
export default function AtlasPage(): React.ReactElement {
const isPhone = useIsPhone()
return isPhone ? <MAtlas /> : <AtlasPageDesktop />
// ViewportRoute in App.tsx picks the branch now, so the phone screen is a
// chunk of its own instead of a dead limb in this one.
return <AtlasPageDesktop />
}
function AtlasPageDesktop(): React.ReactElement {
+1 -11
View File
@@ -14,10 +14,9 @@ import AtlasPage from './AtlasPage';
// confirm popup and the sidebar can be driven into every state directly, which the
// map-click driven suite in AtlasPage.test.tsx cannot reach.
const mocks = vi.hoisted(() => ({ atlas: {} as AtlasController, isPhone: false }));
const mocks = vi.hoisted(() => ({ atlas: {} as AtlasController }));
vi.mock('./atlas/useAtlas', () => ({ useAtlas: () => mocks.atlas }));
vi.mock('../mobile/useIsPhone', () => ({ useIsPhone: () => mocks.isPhone }));
vi.mock('../components/Layout/Navbar', () => ({
default: () => React.createElement('nav', { 'data-testid': 'navbar' }),
}));
@@ -35,7 +34,6 @@ function selectTriggers(): HTMLElement[] {
}
beforeEach(() => {
mocks.isPhone = false;
toast.mockClear();
window.__addToast = toast;
setAtlas();
@@ -54,14 +52,6 @@ afterEach(() => {
});
describe('AtlasPage wiring', () => {
it('FE-PAGE-ATLASW-001: hands the whole screen to the mobile atlas on a phone', () => {
mocks.isPhone = true;
render(<AtlasPage />);
expect(screen.getByRole('button', { name: 'atlas.bucketTab' })).toBeInTheDocument();
expect(screen.queryByTestId('navbar')).not.toBeInTheDocument();
});
it('FE-PAGE-ATLASW-002: shows navbar and spinner while the atlas is loading', () => {
setAtlas({ loading: true });
render(<AtlasPage />);
+3 -4
View File
@@ -16,15 +16,14 @@ import CollectionPlaceDetail from '../components/Collections/CollectionPlaceDeta
import LabelManager from '../components/Collections/LabelManager'
import BulkAssignLabelModal from '../components/Collections/BulkAssignLabelModal'
import { useCollections } from './collections/useCollections'
import { useIsPhone } from '../mobile/useIsPhone'
import MCollections from '../mobile/screens/collections/MCollections'
import EmptyState from '../components/shared/EmptyState'
import '../styles/dashboard.css'
import '../styles/collections.css'
export default function CollectionsPage(): React.ReactElement {
const isPhone = useIsPhone()
return isPhone ? <MCollections /> : <CollectionsPageDesktop />
// ViewportRoute in App.tsx picks the branch now, so the phone screen is a
// chunk of its own instead of a dead limb in this one.
return <CollectionsPageDesktop />
}
function CollectionsPageDesktop(): React.ReactElement {
@@ -8,10 +8,7 @@ import { render, screen, fireEvent } from '../../tests/helpers/render'
import CollectionsPage from './CollectionsPage'
vi.mock('../components/Layout/Navbar', () => ({ default: () => <nav data-testid="navbar" /> }))
vi.mock('../mobile/screens/collections/MCollections', () => ({ default: () => <div data-testid="mobile-collections" /> }))
let isPhone = false
vi.mock('../mobile/useIsPhone', () => ({ useIsPhone: () => isPhone }))
vi.mock('../components/Collections/ListsRail', () => ({
default: (p: {
@@ -299,19 +296,10 @@ function renderPage(overrides: Hook = {}): Hook {
}
beforeEach(() => {
isPhone = false
mockUseCollections.mockReset()
})
describe('CollectionsPage — shell', () => {
it('FE-PAGE-COLLPAGE-001: hands a phone viewport to the mobile screen', () => {
isPhone = true
mockUseCollections.mockReturnValue(makeHook())
render(<CollectionsPage />)
expect(screen.getByTestId('mobile-collections')).toBeInTheDocument()
expect(screen.queryByTestId('navbar')).toBeNull()
})
it('FE-PAGE-COLLPAGE-002: renders the desktop shell with hero, rail and list', () => {
renderPage()
expect(screen.getByTestId('navbar')).toBeInTheDocument()
@@ -1,7 +1,6 @@
import React from 'react';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { http, HttpResponse } from 'msw';
import { act } from '@testing-library/react';
import { render, screen, fireEvent, waitFor, within } from '../../tests/helpers/render';
import { server } from '../../tests/helpers/msw/server';
import { resetAllStores, seedStore } from '../../tests/helpers/store';
@@ -43,11 +42,6 @@ function installMatchMedia(): void {
});
}
function setPhone(next: boolean): void {
phone = next;
mqListeners.forEach(set => set.forEach(l => l({ matches: next } as MediaQueryListEvent)));
}
const TRIP = buildTrip({ id: 101, title: 'Paris Adventure', start_date: '2026-07-01', end_date: '2026-07-10' });
function onlyTrips(trips: unknown[]) {
@@ -375,14 +369,4 @@ describe('DashboardPage (desktop)', () => {
}
});
it('FE-PAGE-DESKDASH-017: crossing the phone breakpoint swaps in the mobile dashboard', async () => {
const { container } = render(<DashboardPage />);
await waitFor(() => expect(screen.getAllByText('Paris Adventure').length).toBeGreaterThan(0));
expect(container.querySelector('.trek-dash')).toBeInTheDocument();
act(() => { setPhone(true); });
await waitFor(() => expect(container.querySelector('.trek-dash')).toBeNull());
expect(screen.getByRole('button', { name: 'TREK' })).toBeInTheDocument();
});
});
+3 -6
View File
@@ -32,8 +32,6 @@ import { convertDistance, getDistanceUnitLabel } from '../utils/units'
import { useSettingsStore } from '../store/settingsStore'
import { useAddonStore } from '../store/addonStore'
import { normalizeAppearance } from '@trek/shared'
import { useIsPhone } from '../mobile/useIsPhone'
import MDashboard from '../mobile/screens/dashboard/MDashboard'
import '../styles/dashboard.css'
const GRADIENTS = [
@@ -97,10 +95,9 @@ const RES_ICON: Record<string, React.ReactElement> = {
const RES_TYPE_CLASS: Record<string, string> = { flight: 'flight', hotel: 'hotel', restaurant: 'food' }
export default function DashboardPage(): React.ReactElement {
// Phones get the mobile screen, everything else the untouched desktop page.
// Both branches call hooks of their own, so the split lives above them.
const isPhone = useIsPhone()
return isPhone ? <MDashboard /> : <DashboardPageDesktop />
// ViewportRoute in App.tsx picks the branch now, so the phone screen is a
// chunk of its own instead of a dead limb in this one.
return <DashboardPageDesktop />
}
function DashboardPageDesktop(): React.ReactElement {
+3 -4
View File
@@ -6,12 +6,11 @@ import EmptyState from '../components/shared/EmptyState'
import { Spinner } from '../components/shared/Spinner'
import InAppNotificationItem from '../components/Notifications/InAppNotificationItem.tsx'
import { useInAppNotifications } from './inAppNotifications/useInAppNotifications'
import { useIsPhone } from '../mobile/useIsPhone'
import MNotifications from '../mobile/screens/notifications/MNotifications'
export default function InAppNotificationsPage(): React.ReactElement {
const isPhone = useIsPhone()
return isPhone ? <MNotifications /> : <InAppNotificationsPageDesktop />
// ViewportRoute in App.tsx picks the branch now, so the phone screen is a
// chunk of its own instead of a dead limb in this one.
return <InAppNotificationsPageDesktop />
}
function InAppNotificationsPageDesktop(): React.ReactElement {
+3 -4
View File
@@ -23,12 +23,11 @@ import { GalleryView } from '../components/Journey/JourneyDetailPageGalleryView'
import { EntryEditor } from '../components/Journey/JourneyDetailPageEntryEditor'
import { AddTripDialog } from '../components/Journey/JourneyDetailPageAddTripDialog'
import { JourneySettingsDialog } from '../components/Journey/JourneyDetailPageSettingsDialog'
import { useIsPhone } from '../mobile/useIsPhone'
import MJourneyDetail from '../mobile/screens/journey/MJourneyDetail'
export default function JourneyDetailPage() {
const isPhone = useIsPhone()
return isPhone ? <MJourneyDetail /> : <JourneyDetailPageDesktop />
// ViewportRoute in App.tsx picks the branch now, so the phone screen is a
// chunk of its own instead of a dead limb in this one.
return <JourneyDetailPageDesktop />
}
function JourneyDetailPageDesktop() {
+3 -4
View File
@@ -7,8 +7,6 @@ import {
import type { Journey } from '../store/journeyStore'
import { computeJourneyLifecycle } from '../utils/journeyLifecycle'
import { useJourney } from './journey/useJourney'
import { useIsPhone } from '../mobile/useIsPhone'
import MJourney from '../mobile/screens/journey/MJourney'
const GRADIENTS = [
'linear-gradient(135deg, #0F172A 0%, #6366F1 45%, #EC4899 100%)',
@@ -24,8 +22,9 @@ function pickGradient(id: number): string {
}
export default function JourneyPage() {
const isPhone = useIsPhone()
return isPhone ? <MJourney /> : <JourneyPageDesktop />
// ViewportRoute in App.tsx picks the branch now, so the phone screen is a
// chunk of its own instead of a dead limb in this one.
return <JourneyPageDesktop />
}
function JourneyPageDesktop() {
+3 -4
View File
@@ -14,12 +14,11 @@ import OfflineTab from '../components/Settings/OfflineTab'
import PluginSettingsTab from '../components/Settings/PluginSettingsTab'
import { usePluginStore } from '../store/pluginStore'
import { useSettings } from './settings/useSettings'
import { useIsPhone } from '../mobile/useIsPhone'
import MSettings from '../mobile/screens/settings/MSettings'
export default function SettingsPage(): React.ReactElement {
const isPhone = useIsPhone()
return isPhone ? <MSettings /> : <SettingsPageDesktop />
// ViewportRoute in App.tsx picks the branch now, so the phone screen is a
// chunk of its own instead of a dead limb in this one.
return <SettingsPageDesktop />
}
function SettingsPageDesktop(): React.ReactElement {
+3 -7
View File
@@ -48,9 +48,7 @@ import { ListTodo, Upload, Plus, Trash2, FolderPlus } from 'lucide-react'
import { useTripPlanner } from './tripPlanner/useTripPlanner'
import { usePoiExplore } from '../components/Map/usePoiExplore'
import PoiCategoryPill from '../components/Map/PoiCategoryPill'
import { useIsPhone } from '../mobile/useIsPhone'
import { useTouchDragBridge } from '../hooks/useTouchDragBridge'
import MTripShell from '../mobile/screens/trip/MTripShell'
// The tab panels are the planner's dead weight: each one mounts only while its
// own tab is active, so the page chunk carried code most sessions never run. They
@@ -226,11 +224,9 @@ function ListsContainer({ tripId, packingItems, todoItems }: { tripId: number; p
}
export default function TripPlannerPage(): React.ReactElement | null {
// Below md the trip renders as the new mobile shell; the desktop planner is
// untouched for tablets and up. Each branch mounts its own useTripPlanner,
// so no hook is called conditionally here.
const isPhone = useIsPhone()
return isPhone ? <MTripShell /> : <TripPlannerPageDesktop />
// ViewportRoute in App.tsx picks the branch now, so the phone screen is a
// chunk of its own instead of a dead limb in this one.
return <TripPlannerPageDesktop />
}
function TripPlannerPageDesktop(): React.ReactElement | null {
@@ -42,12 +42,6 @@ function props(name: string): Record<string, AnyProp> {
return (captured[name] ?? {}) as Record<string, AnyProp>
}
const isPhone = vi.hoisted(() => ({ value: false }))
vi.mock('../mobile/useIsPhone', () => ({ useIsPhone: () => isPhone.value }))
vi.mock('../mobile/screens/trip/MTripShell', () => ({
default: () => React.createElement('div', { 'data-testid': 'm-trip-shell' }),
}))
vi.mock('../components/Map/MapViewAuto', () => ({ MapViewAuto: stub('map', 'map-view') }))
vi.mock('../components/Map/MapCompassPill', () => ({ MapCompassPill: stub('compass', 'compass-pill') }))
vi.mock('../components/Map/PoiCategoryPill', () => ({ default: stub('poiPill', 'poi-pill') }))
@@ -301,21 +295,12 @@ beforeEach(() => {
resetAllStores()
for (const key of Object.keys(captured)) delete captured[key]
confirmDialogs.length = 0
isPhone.value = false
hookState = baseState()
seedStore(useAuthStore, { isAuthenticated: true, user: buildUser({ id: 5 }) })
useTripStore.setState({ loadBudgetItems: vi.fn(async () => undefined) } as never)
})
describe('TripPlannerPage — shell', () => {
it('FE-PAGE-TPW-001: below the md breakpoint the mobile trip shell replaces the planner', () => {
isPhone.value = true
renderPage()
expect(screen.getByTestId('m-trip-shell')).toBeInTheDocument()
expect(screen.queryByTestId('map-view')).not.toBeInTheDocument()
})
it('FE-PAGE-TPW-002: the splash holds the page until the trip finished loading', () => {
renderPage({ isLoading: true, splashDone: false })
+3 -4
View File
@@ -10,12 +10,11 @@ import VacaySettings from '../components/Vacay/VacaySettings'
import { Plus, Minus, ChevronLeft, ChevronRight, Settings, CalendarDays, AlertTriangle, Eye, Pencil, Trash2, Unlink, ShieldCheck, SlidersHorizontal } from 'lucide-react'
import Modal from '../components/shared/Modal'
import { useVacay } from './vacay/useVacay'
import { useIsPhone } from '../mobile/useIsPhone'
import MVacay from '../mobile/screens/vacay/MVacay'
export default function VacayPage(): React.ReactElement {
const isPhone = useIsPhone()
return isPhone ? <MVacay /> : <VacayPageDesktop />
// ViewportRoute in App.tsx picks the branch now, so the phone screen is a
// chunk of its own instead of a dead limb in this one.
return <VacayPageDesktop />
}
function VacayPageDesktop(): React.ReactElement {