diff --git a/client/package.json b/client/package.json index 06f600acd..e1938a4bb 100644 --- a/client/package.json +++ b/client/package.json @@ -18,6 +18,7 @@ "lint": "eslint .", "lint:check": "eslint .", "lint:pages": "node scripts/check-page-pattern.mjs", + "check:gl-split": "node scripts/check-gl-split.mjs", "theme:lint": "node scripts/theme-lint.mjs", "theme:lint:strict": "node scripts/theme-lint.mjs --strict", "e2e": "playwright test", diff --git a/client/scripts/check-gl-split.mjs b/client/scripts/check-gl-split.mjs new file mode 100644 index 000000000..31fa69862 --- /dev/null +++ b/client/scripts/check-gl-split.mjs @@ -0,0 +1,47 @@ +import { readdirSync, readFileSync, statSync } from 'node:fs' +import { join } from 'node:path' + +/** + * Fails when a built chunk contains both WebGL map engines. + * + * mapbox-gl and maplibre-gl used to be static imports in the same three + * components, so rollup emitted a single 2.8 MB chunk carrying both. Every map + * user downloaded 1.8 MB of mapbox plus 1.1 MB of maplibre and ran one of them. + * A stray static import anywhere in the map tree brings that straight back, and + * nothing else would notice — the build stays green and the app still works. + * + * The markers are chosen to appear only inside the SDKs themselves, verified in + * node_modules. Do NOT test for the bare string "maplibre": the provider value + * 'maplibre-gl' and the setting key maplibre_style also appear in app code, so it + * matches the shared core chunk too. + */ +const DIR = 'dist/assets' +const MAPBOX = 'events.mapbox.com' // 1 hit in mapbox-gl, 0 in maplibre-gl +const MAPLIBRE = 'maplibregl-canvas' // 2 hits in maplibre-gl, 0 in mapbox-gl + +let mixed = 0 +let found = 0 + +for (const file of readdirSync(DIR).filter(f => f.endsWith('.js'))) { + const path = join(DIR, file) + const src = readFileSync(path, 'utf8') + const hasMapbox = src.includes(MAPBOX) + const hasMaplibre = src.includes(MAPLIBRE) + if (!hasMapbox && !hasMaplibre) continue + + found++ + const size = statSync(path).size + if (hasMapbox && hasMaplibre) { + console.error(`FAIL both GL engines in one chunk: ${file} (${size} B)`) + mixed++ + } else { + console.log(`${hasMapbox ? 'mapbox ' : 'maplibre'} ${file} ${size} B`) + } +} + +if (!found) { + console.error('FAIL no chunk contains either GL engine — did the build run?') + process.exit(1) +} + +process.exit(mixed ? 1 : 0) diff --git a/client/src/components/Files/FileManagerImageLightbox.test.tsx b/client/src/components/Files/FileManagerImageLightbox.test.tsx index e49ff1f5f..f0c470860 100644 --- a/client/src/components/Files/FileManagerImageLightbox.test.tsx +++ b/client/src/components/Files/FileManagerImageLightbox.test.tsx @@ -182,11 +182,12 @@ describe('ImageLightbox', () => { expect(screen.getAllByRole('button')).toHaveLength(3) }) - it('FE-W4LBX-015: a video plays in the player and never mints a download token', () => { + it('FE-W4LBX-015: a video plays in the player and never mints a download token', async () => { const video = file({ id: 9, original_name: 'clip.mp4', mime_type: 'video/mp4', url: '/f/clip.mp4' }) const { container } = render( {}} />) - expect(screen.getByTestId('video')).toHaveAttribute('data-src', '/f/clip.mp4') + // The player loads on demand now — plyr no longer ships with the file manager. + expect(await screen.findByTestId('video')).toHaveAttribute('data-src', '/f/clip.mp4') expect(getAuthUrl).not.toHaveBeenCalled() expect(container.querySelector('img')).toBeNull() }) diff --git a/client/src/components/Files/FileManagerImageLightbox.tsx b/client/src/components/Files/FileManagerImageLightbox.tsx index 0afaa127a..0b2b53911 100644 --- a/client/src/components/Files/FileManagerImageLightbox.tsx +++ b/client/src/components/Files/FileManagerImageLightbox.tsx @@ -5,7 +5,7 @@ import type { TripFile } from '../../types' import { getAuthUrl } from '../../api/authUrl' import { openFile as openFileUrl } from '../../utils/fileDownload' import { triggerDownload, isVideo } from './FileManager.helpers' -import VideoPlayer from '../Journey/VideoPlayer' +import VideoPlayer from '../Journey/VideoPlayerLazy' // Image lightbox with gallery navigation interface ImageLightboxProps { diff --git a/client/src/components/Journey/JourneyMapAuto.tsx b/client/src/components/Journey/JourneyMapAuto.tsx index 3e8c5c1c4..ab9332e1f 100644 --- a/client/src/components/Journey/JourneyMapAuto.tsx +++ b/client/src/components/Journey/JourneyMapAuto.tsx @@ -1,12 +1,10 @@ -import { forwardRef, lazy, Suspense, useImperativeHandle, useRef } from 'react' +import { forwardRef, Suspense, useImperativeHandle, useRef } from 'react' import { useSettingsStore } from '../../store/settingsStore' import JourneyMap, { type JourneyMapHandle } from './JourneyMap' import ErrorBoundary from '../shared/ErrorBoundary' import type { JourneyMapGLHandle } from './JourneyMapGL' -// Lazy-load the GL renderer (and its ~230 KB gzip engine) so Leaflet-only -// installs never download it — it ships only once a GL provider is picked. -const JourneyMapGL = lazy(() => import('./JourneyMapGL')) +import { JourneyMapGLMapbox, JourneyMapGLMaplibre } from '../Map/glLazy' // Unified handle — both providers expose the same three methods. export type JourneyMapAutoHandle = JourneyMapHandle @@ -52,12 +50,14 @@ const JourneyMapAuto = forwardRef(function JourneyM invalidateSize: () => (useGL ? glRef.current : leafletRef.current)?.invalidateSize(), }), [useGL]) + // One chunk per engine — see MapViewAuto. + const JourneyMapGL = glProvider === 'maplibre-gl' ? JourneyMapGLMaplibre : JourneyMapGLMapbox if (useGL) { return ( // See MapViewAuto: the boundary has to sit outside the Suspense to catch a // chunk that never arrives. // eslint-disable-next-line @typescript-eslint/no-explicit-any - }> + }> {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} diff --git a/client/src/components/Journey/JourneyMapGL.test.tsx b/client/src/components/Journey/JourneyMapGL.test.tsx index 193462a0b..ce4f473ce 100644 --- a/client/src/components/Journey/JourneyMapGL.test.tsx +++ b/client/src/components/Journey/JourneyMapGL.test.tsx @@ -119,9 +119,28 @@ import { render, screen, act } from '../../../tests/helpers/render' import { resetAllStores, seedStore } from '../../../tests/helpers/store' import { useSettingsStore } from '../../store/settingsStore' import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings, addTerrainAndSky } from '../Map/mapboxSetup' -import JourneyMapGL from './JourneyMapGL' +import JourneyMapGLWithEngine from './JourneyMapGL' import type { JourneyMapGLHandle } from './JourneyMapGL' +// The engine is a prop now, not a module import — that is what keeps mapbox-gl and +// maplibre-gl in separate chunks. This shim preserves the suite's existing call +// shape and makes the same choice glLazy.tsx makes in production, so the vi.mock +// factories below still stand in for the right SDK. +const JourneyMapGL = React.forwardRef(function JourneyMapGLShim( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + props: any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ref: any +) { + return ( + + ) +}) + const entries = [ { id: 'e1', lat: 48.8566, lng: 2.3522, title: 'Louvre', location_name: 'Paris, France', entry_date: '2025-06-01', dayColor: '#ff0055', dayLabel: 2 }, { id: 'e2', lat: 52.52, lng: 13.405, title: 'Museumsinsel', location_name: 'Berlin, Germany', entry_date: '2025-06-02' }, diff --git a/client/src/components/Journey/JourneyMapGL.tsx b/client/src/components/Journey/JourneyMapGL.tsx index f3e585411..7a1c7c043 100644 --- a/client/src/components/Journey/JourneyMapGL.tsx +++ b/client/src/components/Journey/JourneyMapGL.tsx @@ -1,8 +1,5 @@ import { useEffect, useRef, useImperativeHandle, forwardRef, useCallback } from 'react' -import mapboxgl from 'mapbox-gl' -import maplibregl from 'maplibre-gl' -import 'mapbox-gl/dist/mapbox-gl.css' -import 'maplibre-gl/dist/maplibre-gl.css' +import type mapboxgl from 'mapbox-gl' import { useSettingsStore } from '../../store/settingsStore' import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings, addTerrainAndSky } from '../Map/mapboxSetup' import { MAPBOX_DEFAULT_STYLE, styleForActiveProvider, basemapLanguage, type GlMapProvider } from '../Map/glProviders' @@ -36,6 +33,13 @@ interface Props { fullScreen?: boolean paddingBottom?: number glProvider?: GlMapProvider + /** + * The GL engine, injected instead of imported. Both SDKs used to be pulled in + * statically here, so a single 2.8 MB chunk carried mapbox-gl and maplibre-gl + * together and every map user downloaded both while only one ever ran. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + gl: any } interface Item { @@ -206,7 +210,7 @@ function markerHtml(dayColor: string, dayLabel: number, highlighted: boolean): H const EMPTY_TRAIL: { lat: number; lng: number }[] = [] const JourneyMapGL = forwardRef(function JourneyMapGL( - { entries, trail, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, glProvider = 'mapbox-gl' }, + { entries, trail, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, glProvider = 'mapbox-gl', gl }, ref ) { const stableTrail = trail || EMPTY_TRAIL @@ -217,7 +221,6 @@ const JourneyMapGL = forwardRef(function JourneyMapGL const mapboxQuality = useSettingsStore(s => s.settings.mapbox_quality_mode === true) const mapLang = useSettingsStore(s => s.settings.language) const isMapLibre = glProvider === 'maplibre-gl' - const gl = (isMapLibre ? maplibregl : mapboxgl) as any const glStyle = styleForActiveProvider(glProvider, rawMapboxStyle, rawMaplibreStyle) const enableMapbox3d = !isMapLibre && mapbox3d const containerRef = useRef(null) @@ -344,7 +347,7 @@ const JourneyMapGL = forwardRef(function JourneyMapGL // inside the same effect so they stay in sync with the active style. useEffect(() => { if (!containerRef.current || (!isMapLibre && !mapboxToken)) return - if (!isMapLibre) mapboxgl.accessToken = mapboxToken + if (!isMapLibre) gl.accessToken = mapboxToken const items = buildItems(entries) itemsRef.current = items diff --git a/client/src/components/Journey/PhotoLightbox.test.tsx b/client/src/components/Journey/PhotoLightbox.test.tsx index a2dceb702..e66f821c1 100644 --- a/client/src/components/Journey/PhotoLightbox.test.tsx +++ b/client/src/components/Journey/PhotoLightbox.test.tsx @@ -151,14 +151,15 @@ describe('PhotoLightbox', () => { expect(screen.getByText('Mountain trail')).toBeInTheDocument(); }); - it('FE-COMP-LIGHTBOX-014: renders a video item through the player instead of an image', () => { + it('FE-COMP-LIGHTBOX-014: renders a video item through the player instead of an image', async () => { render( , ); - expect(screen.getByTestId('video-player')).toHaveAttribute('src', '/videos/1.mp4'); + // The player loads on demand now — plyr no longer ships with the journal. + expect(await screen.findByTestId('video-player')).toHaveAttribute('src', '/videos/1.mp4'); expect(screen.queryByRole('img')).not.toBeInTheDocument(); }); diff --git a/client/src/components/Journey/PhotoLightbox.tsx b/client/src/components/Journey/PhotoLightbox.tsx index 8cf3da614..64f9202c6 100644 --- a/client/src/components/Journey/PhotoLightbox.tsx +++ b/client/src/components/Journey/PhotoLightbox.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useCallback, useRef } from 'react' import { ChevronLeft, ChevronRight, X } from 'lucide-react' -import VideoPlayer from './VideoPlayer' +import VideoPlayer from './VideoPlayerLazy' interface LightboxPhoto { id: string diff --git a/client/src/components/Journey/VideoPlayerLazy.tsx b/client/src/components/Journey/VideoPlayerLazy.tsx new file mode 100644 index 000000000..2af91c8ea --- /dev/null +++ b/client/src/components/Journey/VideoPlayerLazy.tsx @@ -0,0 +1,42 @@ +import { Suspense } from 'react' +import type React from 'react' +import { lazyWithRetry } from '../../utils/lazyWithRetry' +import ErrorBoundary from '../shared/ErrorBoundary' + +/** + * Plyr (112 kB raw / 33 kB gzip of JS plus 32 kB / 5 kB of CSS) hung statically off + * three lightboxes, and through them off the FileManager, JournalBody and MTripShell + * chunks — downloaded with the route, whether or not a lightbox was ever opened and + * whether or not it held a video. + * + * The lightboxes only render the player on their video branch anyway, so the split + * costs one frame of placeholder and nothing else. + */ +const VideoPlayer = lazyWithRetry(() => import('./VideoPlayer')) + +export default function VideoPlayerLazy(props: React.ComponentProps) { + // A black rectangle in the target geometry rather than null, so the lightbox does + // not jump once the chunk lands. + const placeholder = ( +
+ ) + return ( + // Boundary outside the Suspense, as in MapViewAuto: Suspense owns the pending + // promise, a rejected one flies past it and would otherwise take the whole + // lightbox down with it. + + + + + + ) +} diff --git a/client/src/components/Map/MapViewAuto.tsx b/client/src/components/Map/MapViewAuto.tsx index fba1d69c2..d9a5024ec 100644 --- a/client/src/components/Map/MapViewAuto.tsx +++ b/client/src/components/Map/MapViewAuto.tsx @@ -1,11 +1,8 @@ -import { lazy, Suspense } from 'react' +import { Suspense } from 'react' import { useSettingsStore } from '../../store/settingsStore' import { MapView } from './MapView' import ErrorBoundary from '../shared/ErrorBoundary' - -// MapLibre/Mapbox pull in a ~230 KB (gzip) GL engine. Lazy-load the GL renderer so -// Leaflet-only installs never download it — it ships only once a GL provider is picked. -const MapViewGL = lazy(() => import('./MapViewGL').then(m => ({ default: m.MapViewGL }))) +import { MapViewGLMapbox, MapViewGLMaplibre } from './glLazy' // Auto-selects the map renderer based on user settings. Keeps the existing // Leaflet MapView untouched so the Mapbox GL variant can mature iteratively @@ -24,6 +21,9 @@ export function MapViewAuto(props: any) { const glProvider = provider === 'maplibre-gl' ? 'maplibre-gl' : provider === 'mapbox-gl' && token ? 'mapbox-gl' : null + // One chunk per engine: picking the binding here is what keeps mapbox-gl and + // maplibre-gl out of each other's downloads. + const MapViewGL = glProvider === 'maplibre-gl' ? MapViewGLMaplibre : MapViewGLMapbox if (glProvider) { // Render the previous Leaflet map as the fallback so there's no blank flash // while the GL chunk loads on first use. @@ -31,7 +31,9 @@ export function MapViewAuto(props: any) { // Outside the Suspense on purpose: Suspense handles the pending promise, // a rejected one (chunk gone after a deploy) throws past it. Falling back // to Leaflet keeps a usable map instead of an error card. - }> + // resetKeys: with two engine chunks, a failure under one provider must not + // keep showing Leaflet after the user switches to the other. + }> }> diff --git a/client/src/components/Map/MapViewGL.test.tsx b/client/src/components/Map/MapViewGL.test.tsx index 86a1d5111..a8262dc4d 100644 --- a/client/src/components/Map/MapViewGL.test.tsx +++ b/client/src/components/Map/MapViewGL.test.tsx @@ -208,7 +208,17 @@ vi.mock('../../services/photoService', () => ({ getAllThumbs: vi.fn(() => ({})), })) -import { MapViewGL } from './MapViewGL' +import mapboxgl from 'mapbox-gl' +import { MapViewGL as MapViewGLWithEngine } from './MapViewGL' + +// The engine is a prop now, not a module import — that is what keeps mapbox-gl and +// maplibre-gl in separate chunks. This shim preserves the suite's existing call +// shape and makes the same choice glLazy.tsx makes in production, so the vi.mock +// factories below still stand in for the right SDK. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function MapViewGL(props: any) { + return +} import * as mapboxSetup from './mapboxSetup' import * as photoService from '../../services/photoService' import { ReservationMapboxOverlay } from './reservationsMapbox' diff --git a/client/src/components/Map/MapViewGL.tsx b/client/src/components/Map/MapViewGL.tsx index 6e95c306c..9482f9dab 100644 --- a/client/src/components/Map/MapViewGL.tsx +++ b/client/src/components/Map/MapViewGL.tsx @@ -1,9 +1,6 @@ import { useEffect, useRef, useMemo, useState, createElement } from 'react' import { renderToStaticMarkup } from 'react-dom/server' -import mapboxgl from 'mapbox-gl' -import maplibregl from 'maplibre-gl' -import 'mapbox-gl/dist/mapbox-gl.css' -import 'maplibre-gl/dist/maplibre-gl.css' +import type mapboxgl from 'mapbox-gl' import { useSettingsStore } from '../../store/settingsStore' import { useAuthStore } from '../../store/authStore' import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService' @@ -120,6 +117,13 @@ interface Props { onPoiClick?: (poi: Poi) => void onViewportChange?: (bbox: { south: number; west: number; north: number; east: number }) => void glProvider?: GlMapProvider + /** + * The GL engine, injected instead of imported. Both SDKs used to be pulled in + * statically here, so a single 2.8 MB chunk carried mapbox-gl and maplibre-gl + * together and every map user downloaded both while only one ever ran. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + gl: any // eslint-disable-next-line @typescript-eslint/no-explicit-any onMapReady?: (map: any | null) => void } @@ -357,6 +361,7 @@ export function MapViewGL({ onPoiClick, onViewportChange, glProvider = 'mapbox-gl', + gl, onMapReady, }: Props) { const rawMapboxStyle = useSettingsStore(s => s.settings.mapbox_style || MAPBOX_DEFAULT_STYLE) @@ -367,7 +372,6 @@ export function MapViewGL({ const showEndpointLabels = useSettingsStore(s => s.settings.map_booking_labels) === true const mapLang = useSettingsStore(s => s.settings.language) const isMapLibre = glProvider === 'maplibre-gl' - const gl = (isMapLibre ? maplibregl : mapboxgl) as any const glStyle = styleForActiveProvider(glProvider, rawMapboxStyle, rawMaplibreStyle) const enableMapbox3d = !isMapLibre && mapbox3d const placesPhotosEnabled = useAuthStore(s => s.placesPhotosEnabled) @@ -444,7 +448,7 @@ export function MapViewGL({ // Build/rebuild the map on provider/style/token/3d change useEffect(() => { if (!containerRef.current || (!isMapLibre && !mapboxToken)) return - if (!isMapLibre) mapboxgl.accessToken = mapboxToken + if (!isMapLibre) gl.accessToken = mapboxToken // Open framed on the places rather than on the caller's default: a trip in Japan should // show Japan straight away, not the world view followed by a flight across the planet. diff --git a/client/src/components/Map/engines/mapbox.ts b/client/src/components/Map/engines/mapbox.ts new file mode 100644 index 000000000..f1e114494 --- /dev/null +++ b/client/src/components/Map/engines/mapbox.ts @@ -0,0 +1,11 @@ +import mapboxgl from 'mapbox-gl' +import 'mapbox-gl/dist/mapbox-gl.css' + +/** + * The only module in the client that pulls mapbox-gl in at runtime. + * + * It has to stay reachable through `import('./engines/mapbox')` and nothing else: + * a static import from anywhere in the tree drags 1.8 MB into the parent chunk and + * quietly undoes the split. `npm run check:gl-split` fails on that. + */ +export default mapboxgl diff --git a/client/src/components/Map/engines/maplibre.ts b/client/src/components/Map/engines/maplibre.ts new file mode 100644 index 000000000..662c13994 --- /dev/null +++ b/client/src/components/Map/engines/maplibre.ts @@ -0,0 +1,13 @@ +import maplibregl from 'maplibre-gl' +import 'maplibre-gl/dist/maplibre-gl.css' + +/** + * The only module in the client that pulls maplibre-gl in at runtime. Same rule as + * its mapbox sibling: reachable through a dynamic import and no other way. + * + * The two engines share their API surface for everything TREK uses — Map, Marker, + * Popup, LngLatBounds — which is why the components take the engine as a prop and + * stay engine-agnostic. Where the two genuinely differ, the caller already guards + * on the provider (projection, accessToken, aroundCenter). + */ +export default maplibregl diff --git a/client/src/components/Map/glLazy.tsx b/client/src/components/Map/glLazy.tsx new file mode 100644 index 000000000..870095475 --- /dev/null +++ b/client/src/components/Map/glLazy.tsx @@ -0,0 +1,86 @@ +import { forwardRef } from 'react' +import { lazyWithRetry } from '../../utils/lazyWithRetry' + +/** + * The six ways into a GL map, one chunk per engine. + * + * mapbox-gl and maplibre-gl used to be static imports inside MapViewGL, + * JourneyMapGL and the settings preview, so rollup put both SDKs into a single + * 2.8 MB chunk. Every map user downloaded 1.8 MB of mapbox plus 1.1 MB of + * maplibre and then ran exactly one of them. + * + * Pairing the component with its engine behind the lazy boundary is what + * separates them: no module reaches both SDKs statically any more, so rolldown + * emits one shared core chunk and two engine chunks. The engine arrives as a + * prop — see the `gl` prop on each component. + * + * Both imports start together rather than in sequence: the engine is by far the + * larger of the two, and waiting for the component first would add its round + * trip in front of the download that actually costs time. + */ + +export const MapViewGLMapbox = lazyWithRetry(async () => { + const [component, engine] = await Promise.all([import('./MapViewGL'), import('./engines/mapbox')]) + const MapViewGL = component.MapViewGL + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return { default: (props: any) => } +}) + +export const MapViewGLMaplibre = lazyWithRetry(async () => { + const [component, engine] = await Promise.all([import('./MapViewGL'), import('./engines/maplibre')]) + const MapViewGL = component.MapViewGL + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return { default: (props: any) => } +}) + +export const JourneyMapGLMapbox = lazyWithRetry(async () => { + const [component, engine] = await Promise.all([ + import('../Journey/JourneyMapGL'), + import('./engines/mapbox'), + ]) + const JourneyMapGL = component.default + return { + // forwardRef because the journey map exposes an imperative handle + // (highlightMarker / focusMarker / invalidateSize) that has to survive the wrapper. + /* eslint-disable @typescript-eslint/no-explicit-any */ + default: forwardRef(function JourneyMapGLMapboxBinding(props: any, ref: any) { + return + }), + /* eslint-enable @typescript-eslint/no-explicit-any */ + } +}) + +export const JourneyMapGLMaplibre = lazyWithRetry(async () => { + const [component, engine] = await Promise.all([ + import('../Journey/JourneyMapGL'), + import('./engines/maplibre'), + ]) + const JourneyMapGL = component.default + return { + /* eslint-disable @typescript-eslint/no-explicit-any */ + default: forwardRef(function JourneyMapGLMaplibreBinding(props: any, ref: any) { + return + }), + /* eslint-enable @typescript-eslint/no-explicit-any */ + } +}) + +export const GlMapPreviewMapbox = lazyWithRetry(async () => { + const [component, engine] = await Promise.all([ + import('../Settings/MapboxPreview'), + import('./engines/mapbox'), + ]) + const GlMapPreview = component.default + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return { default: (props: any) => } +}) + +export const GlMapPreviewMaplibre = lazyWithRetry(async () => { + const [component, engine] = await Promise.all([ + import('../Settings/MapboxPreview'), + import('./engines/maplibre'), + ]) + const GlMapPreview = component.default + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return { default: (props: any) => } +}) diff --git a/client/src/components/Settings/MapSettingsTab.tsx b/client/src/components/Settings/MapSettingsTab.tsx index b7ae3fd23..b6dfaf58d 100644 --- a/client/src/components/Settings/MapSettingsTab.tsx +++ b/client/src/components/Settings/MapSettingsTab.tsx @@ -1,14 +1,14 @@ -import React, { useState, useEffect, useMemo, useRef, lazy, Suspense } from 'react' +import React, { useState, useEffect, useMemo, useRef, Suspense } from 'react' import { Map, Save, Layers, Box, ChevronDown, Check, Globe2 } from 'lucide-react' import { useTranslation } from '../../i18n' import { useSettingsStore } from '../../store/settingsStore' import { useToast } from '../shared/Toast' import CustomSelect from '../shared/CustomSelect' import { MapView } from '../Map/MapView' -// The preview is the only place that imports both GL engines eagerly, which put -// them in the entry chunk and cancelled out the lazy split in MapViewAuto. Load -// it on demand so a Leaflet-only install never pays for them. -const GlMapPreview = lazy(() => import('./MapboxPreview')) +// The preview loads on demand, and paired with a single engine — a Leaflet-only +// install pays for neither, and a GL install pays for one instead of both. +import ErrorBoundary from '../shared/ErrorBoundary' +import { GlMapPreviewMapbox, GlMapPreviewMaplibre } from '../Map/glLazy' import Section from './Section' import ToggleSwitch from './ToggleSwitch' import type { Place } from '../../types' @@ -165,6 +165,8 @@ export default function MapSettingsTab(): React.ReactElement { const [mapboxStyle, setMapboxStyle] = useState(styleForProvider(initialProvider, slotStyle(initialProvider, settings))) const [mapbox3d, setMapbox3d] = useState(settings.mapbox_3d_enabled !== false) const [mapboxQuality, setMapboxQuality] = useState(settings.mapbox_quality_mode === true) + // One chunk per engine — see components/Map/glLazy.tsx. + const GlMapPreview = provider === 'maplibre-gl' ? GlMapPreviewMaplibre : GlMapPreviewMapbox useEffect(() => { const nextProvider = normalizeProvider(settings.map_provider) @@ -402,6 +404,10 @@ export default function MapSettingsTab(): React.ReactElement {
{provider !== 'leaflet' ? ( + /* A net of its own: the preview is the one place a user flips providers + live, so it is the likeliest chunk to fail — and a broken preview must + not take the rest of the settings tab with it. */ + }> }> + ) : ( // eslint-disable-next-line @typescript-eslint/no-explicit-any React.createElement(MapView as any, { diff --git a/client/src/components/Settings/MapboxPreview.test.tsx b/client/src/components/Settings/MapboxPreview.test.tsx index 882303667..b028225e6 100644 --- a/client/src/components/Settings/MapboxPreview.test.tsx +++ b/client/src/components/Settings/MapboxPreview.test.tsx @@ -44,7 +44,18 @@ vi.mock('../Map/mapboxSetup', () => ({ import { isStandardFamily, supportsCustom3d, addCustom3dBuildings, addTerrainAndSky } from '../Map/mapboxSetup'; import { MAPBOX_DEFAULT_STYLE, OPENFREEMAP_DEFAULT_STYLE } from '../Map/glProviders'; -import GlMapPreview from './MapboxPreview'; +import mapboxgl from 'mapbox-gl'; +import maplibregl from 'maplibre-gl'; +import GlMapPreviewWithEngine from './MapboxPreview'; + +// The engine is a prop now, not a module import — that is what keeps mapbox-gl and +// maplibre-gl in separate chunks. This shim preserves the suite's existing call +// shape and makes the same choice glLazy.tsx makes in production, so the vi.mock +// factories below still stand in for the right SDK. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function GlMapPreview(props: any) { + return ; +} const STREETS = 'mapbox://styles/mapbox/streets-v12'; diff --git a/client/src/components/Settings/MapboxPreview.tsx b/client/src/components/Settings/MapboxPreview.tsx index d3f0a33f3..e5606e719 100644 --- a/client/src/components/Settings/MapboxPreview.tsx +++ b/client/src/components/Settings/MapboxPreview.tsx @@ -1,8 +1,4 @@ import { useEffect, useRef } from 'react' -import mapboxgl from 'mapbox-gl' -import maplibregl from 'maplibre-gl' -import 'mapbox-gl/dist/mapbox-gl.css' -import 'maplibre-gl/dist/maplibre-gl.css' import { isStandardFamily, supportsCustom3d, addCustom3dBuildings, addTerrainAndSky } from '../Map/mapboxSetup' import { MAPBOX_DEFAULT_STYLE, normalizeStyleForProvider, type GlMapProvider } from '../Map/glProviders' @@ -16,22 +12,28 @@ interface Props { enable3d: boolean quality?: boolean onClick?: (latlng: { lat: number; lng: number }) => void + /** + * The GL engine, injected instead of imported. Both SDKs used to be pulled in + * statically here, so a single 2.8 MB chunk carried mapbox-gl and maplibre-gl + * together and every map user downloaded both while only one ever ran. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + gl: any } -export default function GlMapPreview({ provider = 'mapbox-gl', token = '', style, lat, lng, zoom, enable3d, quality = false, onClick }: Props) { +export default function GlMapPreview({ provider = 'mapbox-gl', token = '', style, lat, lng, zoom, enable3d, quality = false, onClick, gl }: Props) { const containerRef = useRef(null) // eslint-disable-next-line @typescript-eslint/no-explicit-any const mapRef = useRef(null) const onClickRef = useRef(onClick) onClickRef.current = onClick const isMapLibre = provider === 'maplibre-gl' - const gl = (isMapLibre ? maplibregl : mapboxgl) as any const glStyle = normalizeStyleForProvider(provider, style) const enableMapbox3d = !isMapLibre && enable3d useEffect(() => { if (!containerRef.current || (!isMapLibre && !token)) return - if (!isMapLibre) mapboxgl.accessToken = token + if (!isMapLibre) gl.accessToken = token const mapOptions: Record = { container: containerRef.current, diff --git a/client/src/components/Vacay/holidayRegions.ts b/client/src/components/Vacay/holidayRegions.ts index 46d85f1bb..7ca5e9c5a 100644 --- a/client/src/components/Vacay/holidayRegions.ts +++ b/client/src/components/Vacay/holidayRegions.ts @@ -1,5 +1,4 @@ import apiClient from '../../api/client' -import iso31662 from 'iso-3166-2' import { SCHOOL_HOLIDAY_COUNTRY_CONFIG } from '../../vacay/schoolHolidayCountries' // Loads the subdivision (state/region) options for a holiday-calendar country. @@ -17,6 +16,13 @@ export async function fetchRegionOptions(country: string): Promise<{ value: stri const hasRegions = r.data.some(h => h.counties && h.counties.length > 0) if (!hasRegions) return [] + // Loaded here, after the hasRegions check: the package is a single 238 kB data + // blob (64 kB gzip, no tree-shaking to be had) and is only needed for countries + // that get a region picker at all. Deliberately inside the try — if the chunk + // fails, the result is the same empty array as a failed request, rather than an + // unhandled rejection in the two callers that only do .then(setRegions). + const iso31662 = (await import('iso-3166-2')).default + const opts = new Map() // ISO code -> display name const sub = iso31662.country(country)?.sub || {} for (const [code, info] of Object.entries(sub)) opts.set(code, info.name) diff --git a/client/src/mobile/screens/settings/MSettingsMap.tsx b/client/src/mobile/screens/settings/MSettingsMap.tsx index e8bf5bda8..0a8682056 100644 --- a/client/src/mobile/screens/settings/MSettingsMap.tsx +++ b/client/src/mobile/screens/settings/MSettingsMap.tsx @@ -1,12 +1,12 @@ -import React, { useEffect, useMemo, useState, lazy, Suspense } from 'react' +import React, { useEffect, useMemo, useState, Suspense } from 'react' import { Box, Check, ChevronDown, Globe2, Layers, Map, Save } from 'lucide-react' import { useTranslation } from '../../../i18n' import { useSettingsStore } from '../../../store/settingsStore' import { useToast } from '../../../components/shared/Toast' import { MapView } from '../../../components/Map/MapView' -// Lazy for the same reason as the desktop map settings tab: this import is what -// dragged both GL engines into the entry chunk. -const GlMapPreview = lazy(() => import('../../../components/Settings/MapboxPreview')) +// Same as the desktop map settings tab: on demand, and paired with one engine. +import ErrorBoundary from '../../../components/shared/ErrorBoundary' +import { GlMapPreviewMapbox, GlMapPreviewMaplibre } from '../../../components/Map/glLazy' import type { Place } from '../../../types' import { MAPBOX_DEFAULT_STYLE, @@ -145,6 +145,9 @@ export default function MSettingsMap() { const selectedPreset = presets.find((p) => p.url === mapboxStyle) const chevron = + // One chunk per engine — see components/Map/glLazy.tsx. + const GlMapPreview = provider === 'maplibre-gl' ? GlMapPreviewMaplibre : GlMapPreviewMapbox + return ( {t('settings.mapProvider')} @@ -251,6 +254,8 @@ export default function MSettingsMap() {
{provider !== 'leaflet' ? ( + /* See MapSettingsTab: the preview gets its own net. */ + }> }> + ) : ( // eslint-disable-next-line @typescript-eslint/no-explicit-any React.createElement(MapView as any, { diff --git a/client/src/mobile/screens/trip/tabs/MFileLightbox.tsx b/client/src/mobile/screens/trip/tabs/MFileLightbox.tsx index 78e940420..9b3c6829f 100644 --- a/client/src/mobile/screens/trip/tabs/MFileLightbox.tsx +++ b/client/src/mobile/screens/trip/tabs/MFileLightbox.tsx @@ -4,7 +4,7 @@ import { ChevronLeft, ChevronRight, Download, ExternalLink, X } from 'lucide-rea import { getAuthUrl } from '../../../../api/authUrl' import { downloadFile, openFile } from '../../../../utils/fileDownload' import { isVideo } from '../../../../components/Files/FileManager.helpers' -import VideoPlayer from '../../../../components/Journey/VideoPlayer' +import VideoPlayer from '../../../../components/Journey/VideoPlayerLazy' import type { TranslationFn, TripFile } from '../../../../types' interface MFileLightboxProps {