From 057d4ba5038b212c1ec6d5fa02dbe62fb06d15ec Mon Sep 17 00:00:00 2001
From: Maurice <61554723+mauriceboe@users.noreply.github.com>
Date: Fri, 7 Aug 2026 11:51:37 +0200
Subject: [PATCH] perf(client): pick the viewport branch at the route (#1817)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
client/src/App.tsx | 59 ++++++++--
client/src/App.viewport.test.tsx | 111 ++++++++++++++++++
.../src/mobile/components/MRouteFallback.tsx | 19 +++
client/src/pages/AdminPage.test.tsx | 23 ----
client/src/pages/AdminPage.tsx | 7 +-
client/src/pages/AtlasPage.tsx | 7 +-
client/src/pages/AtlasPage.wiring.test.tsx | 12 +-
client/src/pages/CollectionsPage.tsx | 7 +-
.../src/pages/CollectionsPage.wiring.test.tsx | 12 --
.../src/pages/DashboardPage.desktop.test.tsx | 16 ---
client/src/pages/DashboardPage.tsx | 9 +-
client/src/pages/InAppNotificationsPage.tsx | 7 +-
client/src/pages/JourneyDetailPage.tsx | 7 +-
client/src/pages/JourneyPage.tsx | 7 +-
client/src/pages/SettingsPage.tsx | 7 +-
client/src/pages/TripPlannerPage.tsx | 10 +-
.../src/pages/TripPlannerPage.wiring.test.tsx | 15 ---
client/src/pages/VacayPage.tsx | 7 +-
18 files changed, 209 insertions(+), 133 deletions(-)
create mode 100644 client/src/App.viewport.test.tsx
create mode 100644 client/src/mobile/components/MRouteFallback.tsx
diff --git a/client/src/App.tsx b/client/src/App.tsx
index f8ff987c7..ac01a09bb 100644
--- a/client/src/App.tsx
+++ b/client/src/App.tsx
@@ -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 ? :
+}
+
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
return (
@@ -290,7 +327,7 @@ export default function App() {
path="/dashboard"
element={
-
+
}
/>
@@ -324,7 +361,7 @@ export default function App() {
path="/trips/:id"
element={
-
+
}
/>
@@ -340,7 +377,7 @@ export default function App() {
path="/admin"
element={
-
+
}
/>
@@ -348,7 +385,7 @@ export default function App() {
path="/settings"
element={
-
+
}
/>
@@ -364,7 +401,7 @@ export default function App() {
path="/vacay"
element={
-
+
}
/>
@@ -372,7 +409,7 @@ export default function App() {
path="/atlas"
element={
-
+
}
/>
@@ -380,7 +417,7 @@ export default function App() {
path="/journey"
element={
-
+
}
/>
@@ -388,7 +425,7 @@ export default function App() {
path="/journey/:id"
element={
-
+
}
/>
@@ -396,7 +433,7 @@ export default function App() {
path="/collections"
element={
-
+
}
/>
@@ -404,7 +441,7 @@ export default function App() {
path="/collections/:id"
element={
-
+
}
/>
@@ -412,7 +449,7 @@ export default function App() {
path="/notifications"
element={
-
+
}
/>
diff --git a/client/src/App.viewport.test.tsx b/client/src/App.viewport.test.tsx
new file mode 100644
index 000000000..13c37d5e5
--- /dev/null
+++ b/client/src/App.viewport.test.tsx
@@ -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: () =>