diff --git a/client/src/mobile/screens/trip/MTripLoadingSplash.tsx b/client/src/mobile/screens/trip/MTripLoadingSplash.tsx
index 6987fd021..ca0c4b286 100644
--- a/client/src/mobile/screens/trip/MTripLoadingSplash.tsx
+++ b/client/src/mobile/screens/trip/MTripLoadingSplash.tsx
@@ -1,3 +1,4 @@
+import { useEffect, useState } from 'react'
import { useTranslation } from '../../../i18n'
import MDancingTrek from '../../components/MDancingTrek'
import { useTripSplashBeats } from '../../../components/shared/useTripSplashBeats'
@@ -10,6 +11,36 @@ import { useTripSplashBeats } from '../../../components/shared/useTripSplashBeat
* progress dots track which beat is on. Under reduced motion it holds a single
* frame (the real "loading photos" one) with no transitions.
*/
+/**
+ * How long a trip may take to open before the splash comes up. The page
+ * transition holds the page the trip was opened from for up to 800ms
+ * (mobile/routeTransition.ts), so a trip that loads in that time slides in
+ * whole, the way the dashboard slides back in on the way out, and the splash is
+ * only for a slow connection.
+ */
+export const TRIP_SPLASH_AFTER_MS = 1000
+
+/**
+ * While a trip opens. At first nothing but the screen's own background, marked
+ * as a route fallback, which the page transition waits on; the splash only once
+ * the load takes longer than TRIP_SPLASH_AFTER_MS.
+ */
+export function MTripOpening({ title }: { title: string }) {
+ const [slow, setSlow] = useState(false)
+ useEffect(() => {
+ const timer = window.setTimeout(() => setSlow(true), TRIP_SPLASH_AFTER_MS)
+ return () => window.clearTimeout(timer)
+ }, [])
+ if (slow) return
+ return (
+
+ )
+}
+
export default function MTripLoadingSplash({ title }: { title: string }) {
const { t } = useTranslation()
const { reduceMotion, activeIndex, step, steps } = useTripSplashBeats({ liveReducedMotion: true })
diff --git a/client/src/mobile/screens/trip/MTripShell.tsx b/client/src/mobile/screens/trip/MTripShell.tsx
index d8b7dfe1d..99c5ce573 100644
--- a/client/src/mobile/screens/trip/MTripShell.tsx
+++ b/client/src/mobile/screens/trip/MTripShell.tsx
@@ -16,7 +16,7 @@ import MPlacesBrowser from './places/MPlacesBrowser'
import MTripTabPanel from './tabs/MTripTabPanel'
import MTripSheets from './sheets/MTripSheets'
import MReceiptScanButton from './tabs/MReceiptScanButton'
-import MTripLoadingSplash from './MTripLoadingSplash'
+import { MTripOpening } from './MTripLoadingSplash'
import { usePluginDayTints, dayTintBackground } from '../../../components/Plugins/PluginDaySchedule'
import { stageOf } from '../../../components/Roadtrip/roadtripRowModel'
import { badgeLabel, distanceBadge } from './roadtrip/stageBadges'
@@ -450,9 +450,11 @@ export default function MTripShell({
// With the plan beside the map, the map shows the day the plan shows: its
// places framed, its route drawn, the other days' pins put away, exactly as
// a phone does when it switches over to the map.
- // Not before the splash has gone: the map is not there to frame until then.
- const mapReady = !planner.isLoading && planner.splashDone
- const followDayId = mapReady && split && trTab === 'plan' && view === 'plan' && mode !== 'browse' ? planner.selectedDayId : null
+ // Not before the trip has opened: the map is not there to frame until then.
+ // The store can still hold the trip opened before, for the frame before loadTrip resets it.
+ const ownTrip = trip && String(trip.id) === String(tripId) ? trip : null
+ const opened = !planner.isLoading && !!ownTrip
+ const followDayId = opened && split && trTab === 'plan' && view === 'plan' && mode !== 'browse' ? planner.selectedDayId : null
const { autoShowRoute, setExpandedDayIds } = planner
const selectDayRef = useRef(planner.handleSelectDay)
useEffect(() => {
@@ -521,9 +523,9 @@ export default function MTripShell({
openDocSyncSignal,
}
- // Splash — same gate as the desktop page, in the mobile design language.
- if (planner.isLoading || !planner.splashDone) {
- return
+ // No minimum splash as on the desktop: the page transition hides the wait (MTripOpening).
+ if (!opened && (planner.isLoading || trip)) {
+ return
}
if (!trip) return null
diff --git a/client/tests/unit/mobile/trip/MTripShell.test.tsx b/client/tests/unit/mobile/trip/MTripShell.test.tsx
index 9d2b9e59a..4e8e61649 100644
--- a/client/tests/unit/mobile/trip/MTripShell.test.tsx
+++ b/client/tests/unit/mobile/trip/MTripShell.test.tsx
@@ -27,6 +27,7 @@ vi.mock('../../../../src/mobile/screens/trip/tabs/MTripTabPanel', () => ({ defau
vi.mock('../../../../src/mobile/screens/trip/sheets/MTripSheets', () => ({ default: () => null }))
import MTripShell from '../../../../src/mobile/screens/trip/MTripShell'
+import { TRIP_SPLASH_AFTER_MS } from '../../../../src/mobile/screens/trip/MTripLoadingSplash'
/** Last shell api handed to a slot — the object every slot shares. */
let shellApi!: MTripShellApi
@@ -135,15 +136,30 @@ describe('MTripShell', () => {
)
})
- it('FE-MOB-SHELL-001: shows the loading splash with the trip title while the planner loads', () => {
- renderShell({ isLoading: true } as Partial)
- expect(screen.getByText('Japan 2026')).toBeInTheDocument()
- expect(screen.queryByTestId('sheets')).not.toBeInTheDocument()
+ it('FE-MOB-SHELL-001: while the trip loads the page transition holds, and only a slow load brings the splash', () => {
+ vi.useFakeTimers()
+ try {
+ const { container } = renderShell({ isLoading: true } as Partial)
+ expect(container.querySelector('[data-route-fallback]')).toBeInTheDocument()
+ expect(screen.queryByText('Japan 2026')).not.toBeInTheDocument()
+ expect(screen.queryByTestId('sheets')).not.toBeInTheDocument()
+
+ act(() => vi.advanceTimersByTime(TRIP_SPLASH_AFTER_MS))
+ expect(screen.getByText('Japan 2026')).toBeInTheDocument()
+ expect(container.querySelector('[data-route-fallback]')).not.toBeInTheDocument()
+ } finally {
+ vi.useRealTimers()
+ }
})
- it('FE-MOB-SHELL-002: keeps the splash up until the photo warm-up finished', () => {
+ it('FE-MOB-SHELL-002: the trip shows as soon as it has loaded, without the desktop splash minimum', () => {
renderShell({ splashDone: false } as Partial)
- expect(screen.getByText('Japan 2026')).toBeInTheDocument()
+ expect(screen.getByTestId('map-area')).toBeInTheDocument()
+ })
+
+ it('FE-MOB-SHELL-002b: the trip opened before, still in the store, is never shown for the one opening', () => {
+ const { container } = renderShell({ tripId: 2 } as Partial)
+ expect(container.querySelector('[data-route-fallback]')).toBeInTheDocument()
expect(screen.queryByTestId('map-area')).not.toBeInTheDocument()
})
@@ -153,8 +169,14 @@ describe('MTripShell', () => {
})
it('FE-MOB-SHELL-003b: the splash falls back to the brand name before the trip arrives', () => {
- renderShell({ isLoading: true, trip: null } as Partial)
- expect(screen.getByText('TREK')).toBeInTheDocument()
+ vi.useFakeTimers()
+ try {
+ renderShell({ isLoading: true, trip: null } as Partial)
+ act(() => vi.advanceTimersByTime(TRIP_SPLASH_AFTER_MS))
+ expect(screen.getByText('TREK')).toBeInTheDocument()
+ } finally {
+ vi.useRealTimers()
+ }
})
it('FE-MOB-SHELL-004: seeds the running trip on today rather than on day one', () => {
@@ -617,27 +639,27 @@ describe('MTripShell', () => {
// #2880: the day is seeded while the splash still covers the shell, so the
// rail only exists after the selection has settled.
- it('FE-MOB-SHELL-094: the active day is brought into view once the splash lifts (#2880)', () => {
+ it('FE-MOB-SHELL-094: the active day is brought into view once the trip has opened (#2880)', () => {
const scrollIntoView = vi.mocked(Element.prototype.scrollIntoView)
scrollIntoView.mockClear()
stubRects({ left: 480, right: 540 })
- const { planner, rerenderShell } = renderShell({ selectedDayId: 12, splashDone: false } as Partial)
+ const { planner, rerenderShell } = renderShell({ selectedDayId: 12, isLoading: true } as Partial)
expect(scrollIntoView).not.toHaveBeenCalled()
- ;(planner as { splashDone: boolean }).splashDone = true
+ ;(planner as { isLoading: boolean }).isLoading = false
rerenderShell()
expect(scrollIntoView).toHaveBeenCalledTimes(1)
expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'auto', inline: 'center', block: 'nearest' })
})
- it('FE-MOB-SHELL-095: after the splash a chip already in view leaves the rail alone', () => {
+ it('FE-MOB-SHELL-095: once the trip has opened a chip already in view leaves the rail alone', () => {
const scrollIntoView = vi.mocked(Element.prototype.scrollIntoView)
scrollIntoView.mockClear()
stubRects({ left: 40, right: 100 })
- const { planner, rerenderShell } = renderShell({ selectedDayId: 12, splashDone: false } as Partial)
+ const { planner, rerenderShell } = renderShell({ selectedDayId: 12, isLoading: true } as Partial)
- ;(planner as { splashDone: boolean }).splashDone = true
+ ;(planner as { isLoading: boolean }).isLoading = false
rerenderShell()
expect(scrollIntoView).not.toHaveBeenCalled()