mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
perf(map): load one GL engine instead of both (#1819)
* perf(map): load one GL engine instead of both MapViewGL, JourneyMapGL and the settings preview each imported mapbox-gl and maplibre-gl statically, so rollup emitted one 2.83 MB chunk carrying both. Every map user downloaded 1.8 MB of mapbox plus 1.03 MB of maplibre and then ran exactly one of them. The 110 kB stylesheet was merged the same way. mapbox users 2,827,647 -> 1,797,952 B (-1.03 MB, CSS 110.6 -> 40.8 kB) maplibre users 2,827,647 -> 1,027,635 B (-1.80 MB, CSS 110.6 -> 69.8 kB) The engine is a prop now rather than a module import, and the pairing happens behind the lazy boundary in glLazy.tsx. No module reaches both SDKs statically any more, which is the whole mechanism — the components themselves stayed engine-agnostic, they already guarded on the provider everywhere the two genuinely differ. Both imports of a pair start together instead of in sequence: the engine is by far the larger half, and fetching the component first would put its round trip in front of the download that actually costs time. check:gl-split guards it. A stray static import anywhere in the map tree brings the merged chunk straight back with a green build and a working app, so the script greps the built chunks for a marker of each SDK and fails when one file holds both. Not on the bare string "maplibre" — the provider value and the maplibre_style setting key appear in app code too, so that matches the shared core chunk as well. Two boundaries were missing and are added here, because the split makes them reachable: the GL boundaries had no resetKeys, so a chunk failure under one provider kept showing Leaflet after switching to the other, and the settings preview had a Suspense but no boundary at all — that is the one place a user flips providers live, so it is the likeliest chunk to fail. Worth being precise about the gain: the service worker precaches every chunk, so an installed PWA still downloads both engines. What this buys is the critical path on a cold cache, and parse/compile/execute always — a megabyte of unused engine ran on every map open. * perf(client): split out the video player and the ISO subdivision data Two heavyweights that shipped with routes that rarely use them. plyr (112 kB raw / 33 kB gzip, plus 32 kB of CSS) hung off three lightboxes and through them off the FileManager, JournalBody and MTripShell chunks. The lightboxes only render the player on their video branch anyway, so the split costs one frame of placeholder — a black rectangle in the target geometry, so the lightbox does not jump when the chunk lands. iso-3166-2 is a single 238 kB data blob with no tree-shaking to be had, and only fetchRegionOptions touches it. It now loads after the hasRegions check, so it is fetched only for countries that get a region picker at all: holidayRegions drops from 257 kB to 14 kB. The import sits inside the existing try — a failed chunk yields the same empty array as a failed request, rather than an unhandled rejection in the two callers that only do .then(setRegions). tz-lookup is deliberately left alone. TransitSearchPanel builds the departure time it sends to MOTIS out of tzAt(), so a not-yet-loaded window there is a wrong query rather than a late pixel — that one needs its own change with its own care.
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
@@ -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(<ImageLightbox files={[video]} initialIndex={0} onClose={() => {}} />)
|
||||
|
||||
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()
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<JourneyMapAutoHandle, Props>(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
|
||||
<ErrorBoundary boundaryId="journey-map:gl" fallback={<JourneyMap ref={leafletRef} {...(props as any)} />}>
|
||||
<ErrorBoundary boundaryId="journey-map:gl" resetKeys={[glProvider]} fallback={<JourneyMap ref={leafletRef} {...(props as any)} />}>
|
||||
<Suspense fallback={null}>
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
<JourneyMapGL ref={glRef} {...(props as any)} glProvider={glProvider} />
|
||||
|
||||
@@ -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 (
|
||||
<JourneyMapGLWithEngine
|
||||
ref={ref}
|
||||
{...props}
|
||||
gl={props.glProvider === 'maplibre-gl' ? maplibregl : mapboxgl}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
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' },
|
||||
|
||||
@@ -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<JourneyMapGLHandle, Props>(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<JourneyMapGLHandle, Props>(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<HTMLDivElement>(null)
|
||||
@@ -344,7 +347,7 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(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
|
||||
|
||||
@@ -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(
|
||||
<PhotoLightbox
|
||||
photos={[{ id: 'v1', src: '/videos/1.mp4', caption: null, mediaType: 'video' }]}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<typeof VideoPlayer>) {
|
||||
// A black rectangle in the target geometry rather than null, so the lightbox does
|
||||
// not jump once the chunk lands.
|
||||
const placeholder = (
|
||||
<div
|
||||
style={{
|
||||
width: 'min(92vw, 1100px)',
|
||||
aspectRatio: '16 / 9',
|
||||
maxHeight: '92vh',
|
||||
borderRadius: 4,
|
||||
background: '#000',
|
||||
...props.style,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
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.
|
||||
<ErrorBoundary boundaryId="lightbox:video" level="panel" fallback={placeholder}>
|
||||
<Suspense fallback={placeholder}>
|
||||
<VideoPlayer {...props} />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
@@ -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.
|
||||
<ErrorBoundary boundaryId="map:gl" fallback={<MapView {...props} />}>
|
||||
// resetKeys: with two engine chunks, a failure under one provider must not
|
||||
// keep showing Leaflet after the user switches to the other.
|
||||
<ErrorBoundary boundaryId="map:gl" resetKeys={[glProvider]} fallback={<MapView {...props} />}>
|
||||
<Suspense fallback={<MapView {...props} />}>
|
||||
<MapViewGL {...props} glProvider={glProvider} />
|
||||
</Suspense>
|
||||
|
||||
@@ -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 <MapViewGLWithEngine {...props} gl={props.glProvider === 'maplibre-gl' ? maplibregl : mapboxgl} />
|
||||
}
|
||||
import * as mapboxSetup from './mapboxSetup'
|
||||
import * as photoService from '../../services/photoService'
|
||||
import { ReservationMapboxOverlay } from './reservationsMapbox'
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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) => <MapViewGL {...props} gl={engine.default} /> }
|
||||
})
|
||||
|
||||
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) => <MapViewGL {...props} gl={engine.default} /> }
|
||||
})
|
||||
|
||||
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 <JourneyMapGL ref={ref} {...props} gl={engine.default} />
|
||||
}),
|
||||
/* 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 <JourneyMapGL ref={ref} {...props} gl={engine.default} />
|
||||
}),
|
||||
/* 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) => <GlMapPreview {...props} gl={engine.default} /> }
|
||||
})
|
||||
|
||||
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) => <GlMapPreview {...props} gl={engine.default} /> }
|
||||
})
|
||||
@@ -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<string>(styleForProvider(initialProvider, slotStyle(initialProvider, settings)))
|
||||
const [mapbox3d, setMapbox3d] = useState<boolean>(settings.mapbox_3d_enabled !== false)
|
||||
const [mapboxQuality, setMapboxQuality] = useState<boolean>(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 {
|
||||
<div>
|
||||
<div style={{ position: 'relative', inset: 0, height: '200px', width: '100%' }}>
|
||||
{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. */
|
||||
<ErrorBoundary boundaryId="settings:map-preview" resetKeys={[provider]} fallback={<div className="h-full w-full bg-surface-secondary" />}>
|
||||
<Suspense fallback={<div className="h-full w-full bg-surface-secondary animate-pulse" />}>
|
||||
<GlMapPreview
|
||||
provider={provider}
|
||||
@@ -416,6 +422,7 @@ export default function MapSettingsTab(): React.ReactElement {
|
||||
quality={provider === 'mapbox-gl' && mapboxQuality}
|
||||
/>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
) : (
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
React.createElement(MapView as any, {
|
||||
|
||||
@@ -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 <GlMapPreviewWithEngine {...props} gl={props.provider === 'maplibre-gl' ? maplibregl : mapboxgl} />;
|
||||
}
|
||||
|
||||
const STREETS = 'mapbox://styles/mapbox/streets-v12';
|
||||
|
||||
|
||||
@@ -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<HTMLDivElement>(null)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const mapRef = useRef<any | null>(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<string, unknown> = {
|
||||
container: containerRef.current,
|
||||
|
||||
@@ -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<string, string>() // ISO code -> display name
|
||||
const sub = iso31662.country(country)?.sub || {}
|
||||
for (const [code, info] of Object.entries(sub)) opts.set(code, info.name)
|
||||
|
||||
@@ -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 = <ChevronDown size={13} strokeWidth={2} className="flex-none text-m-faint" />
|
||||
|
||||
// One chunk per engine — see components/Map/glLazy.tsx.
|
||||
const GlMapPreview = provider === 'maplibre-gl' ? GlMapPreviewMaplibre : GlMapPreviewMapbox
|
||||
|
||||
return (
|
||||
<MSetCard title={t('settings.map')} icon={Map}>
|
||||
<MSetEyebrow className="mb-[6px]">{t('settings.mapProvider')}</MSetEyebrow>
|
||||
@@ -251,6 +254,8 @@ export default function MSettingsMap() {
|
||||
|
||||
<div className="relative mt-3 h-[200px] w-full overflow-hidden rounded-xl">
|
||||
{provider !== 'leaflet' ? (
|
||||
/* See MapSettingsTab: the preview gets its own net. */
|
||||
<ErrorBoundary boundaryId="settings:map-preview" resetKeys={[provider]} fallback={<div className="h-full w-full bg-m-card" />}>
|
||||
<Suspense fallback={<div className="h-full w-full bg-m-card animate-pulse" />}>
|
||||
<GlMapPreview
|
||||
provider={provider}
|
||||
@@ -263,6 +268,7 @@ export default function MSettingsMap() {
|
||||
quality={provider === 'mapbox-gl' && mapboxQuality}
|
||||
/>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
) : (
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
React.createElement(MapView as any, {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user