feat(mobile): a trip opens without its loading screen on the phone and the tablet

The touch shell waited for the desktop's minimum splash of a second and a half after every trip load. It no longer does: while the trip loads it shows only its background, marked as a route fallback, so the page transition holds the dashboard until the trip is there and the trip slides in whole, as the dashboard slides back on the way out. The splash only comes up when a load takes longer than a second. The trip opened before, still in the store for a frame, is never shown for the one opening.
This commit is contained in:
Maurice
2026-11-08 10:33:49 +01:00
parent 3458238db0
commit 0ef3cbf520
3 changed files with 76 additions and 21 deletions
@@ -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 <MTripLoadingSplash title={title} />
return (
<div
data-route-fallback=""
aria-busy="true"
className="fixed inset-0 z-50 bg-[color:var(--m-bg)] bg-[image:var(--m-scr)]"
/>
)
}
export default function MTripLoadingSplash({ title }: { title: string }) {
const { t } = useTranslation()
const { reduceMotion, activeIndex, step, steps } = useTripSplashBeats({ liveReducedMotion: true })
@@ -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 <MTripLoadingSplash title={trip?.title || ''} />
// No minimum splash as on the desktop: the page transition hides the wait (MTripOpening).
if (!opened && (planner.isLoading || trip)) {
return <MTripOpening title={ownTrip?.title || ''} />
}
if (!trip) return null
@@ -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<TripPlanner>)
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<TripPlanner>)
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<TripPlanner>)
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<TripPlanner>)
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<TripPlanner>)
expect(screen.getByText('TREK')).toBeInTheDocument()
vi.useFakeTimers()
try {
renderShell({ isLoading: true, trip: null } as Partial<TripPlanner>)
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<TripPlanner>)
const { planner, rerenderShell } = renderShell({ selectedDayId: 12, isLoading: true } as Partial<TripPlanner>)
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<TripPlanner>)
const { planner, rerenderShell } = renderShell({ selectedDayId: 12, isLoading: true } as Partial<TripPlanner>)
;(planner as { splashDone: boolean }).splashDone = true
;(planner as { isLoading: boolean }).isLoading = false
rerenderShell()
expect(scrollIntoView).not.toHaveBeenCalled()