diff --git a/client/src/components/PDF/tripMapImage.ts b/client/src/components/PDF/tripMapImage.ts index fd0960425..8c0100408 100644 --- a/client/src/components/PDF/tripMapImage.ts +++ b/client/src/components/PDF/tripMapImage.ts @@ -2,6 +2,7 @@ import { buildRouteOverlay, wrapSvg } from './tripMapSvg' import { hasWebGL } from '../../utils/webgl' import { normalizeStyleForProvider } from '../Map/glProviders' import type { TripOverviewDay } from '../Map/tripRouteGeometry' +import { importChunk } from '../../utils/chunkReload' /** * The trip's route over a real basemap, for the PDF (#1736). @@ -46,7 +47,7 @@ export async function renderTripMapImage( let container: HTMLDivElement | null = null let map: { remove: () => void } | null = null try { - const gl = (await import('../Map/engines/maplibre')).default + const gl = (await importChunk(() => import('../Map/engines/maplibre'))).default container = document.createElement('div') // Off-screen rather than hidden: MapLibre needs a laid-out box with real diff --git a/client/src/components/Planner/TripExportModal.tsx b/client/src/components/Planner/TripExportModal.tsx index 3d9501538..b532c174e 100644 --- a/client/src/components/Planner/TripExportModal.tsx +++ b/client/src/components/Planner/TripExportModal.tsx @@ -10,6 +10,7 @@ import { useSettingsStore } from '../../store/settingsStore' import { useRoadtripSettings } from '../../hooks/useRoadtripSettings' import { useAuthStore } from '../../store/authStore' import { hasPersonalPlan } from '../PDF/pdfScope' +import { importChunk } from '../../utils/chunkReload' /** * What a GPX download can carry. Worded by what someone wants on their device @@ -98,7 +99,7 @@ export function TripExportModal({ // sidebar, so every trip used to pay for it whether or not anyone // exported. A missing chunk lands in the catch and shows the same error // the export already had. - const { downloadTripPDF } = await import('../PDF/TripPDF') + const { downloadTripPDF } = await importChunk(() => import('../PDF/TripPDF')) await downloadTripPDF({ trip, days, places, assignments, categories, dayNotes: flatNotes, reservations, t, locale, timeFormat, distanceUnit, showServiceStops, onlyUserId: mine ? myId : undefined }) onClose() } catch (e) { diff --git a/client/src/components/Vacay/holidayRegions.ts b/client/src/components/Vacay/holidayRegions.ts index 8be378baf..055803627 100644 --- a/client/src/components/Vacay/holidayRegions.ts +++ b/client/src/components/Vacay/holidayRegions.ts @@ -2,6 +2,7 @@ import { schoolHolidayCatalogRepo } from '../../repo/schoolHolidayCatalogRepo' import apiClient from '../../api/client' import { useSettingsStore } from '../../store/settingsStore' import { SCHOOL_HOLIDAY_COUNTRY_CONFIG } from '../../vacay/schoolHolidayCountries' +import { importChunk } from '../../utils/chunkReload' // Loads the subdivision (state/region) options for a holiday-calendar country. // @@ -23,7 +24,7 @@ export async function fetchRegionOptions(country: string): Promise<{ value: stri // 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 iso31662 = (await importChunk(() => import('iso-3166-2'))).default const opts = new Map() // ISO code -> display name const sub = iso31662.country(country)?.sub || {} diff --git a/client/src/i18n/TranslationContext.tsx b/client/src/i18n/TranslationContext.tsx index 492bd7043..4ee437cdf 100644 --- a/client/src/i18n/TranslationContext.tsx +++ b/client/src/i18n/TranslationContext.tsx @@ -11,6 +11,7 @@ import { sanitizeInlineHtml, } from '@trek/shared' import type { TranslationStrings } from '@trek/shared/i18n' +import { importChunk } from '../utils/chunkReload' export { SUPPORTED_LANGUAGES } @@ -122,7 +123,7 @@ export function TranslationProvider({ children }: TranslationProviderProps) { if (!loader) return let cancelled = false - loader().then(mod => { + importChunk(loader).then(mod => { if (!cancelled) setStrings(mod.default) }).catch(err => { // The locale chunk can be gone after a deploy. Keep the strings we have — diff --git a/client/src/mobile/screens/trip/sheets/MExportSheet.tsx b/client/src/mobile/screens/trip/sheets/MExportSheet.tsx index a607ac52d..73c34fc3e 100644 --- a/client/src/mobile/screens/trip/sheets/MExportSheet.tsx +++ b/client/src/mobile/screens/trip/sheets/MExportSheet.tsx @@ -11,6 +11,7 @@ import { hasPersonalPlan } from '../../../../components/PDF/pdfScope' import { INNER_CLS, TileHeader } from './MTripSheetUi' import type { MTripSheetsProps } from '../MTripShell' import type { LucideIcon } from 'lucide-react' +import { importChunk } from '../../../../utils/chunkReload' /** * Export sheet ('export', opened from the Mehr sheet): the desktop day-plan @@ -47,7 +48,7 @@ export default function MExportSheet({ planner, shell }: MTripSheetsProps) { setPdfBusy(mine ? 'mine' : 'all') try { // See DayPlanSidebarToolbar: loaded on demand, not with the trip. - const { downloadTripPDF } = await import('../../../../components/PDF/TripPDF') + const { downloadTripPDF } = await importChunk(() => import('../../../../components/PDF/TripPDF')) await downloadTripPDF({ trip: planner.trip, days: planner.days, diff --git a/client/src/utils/chunkReload.test.ts b/client/src/utils/chunkReload.test.ts index 535a27322..96e964aa6 100644 --- a/client/src/utils/chunkReload.test.ts +++ b/client/src/utils/chunkReload.test.ts @@ -1,4 +1,4 @@ -import { isChunkLoadError, reloadOnceForChunk, clearChunkReloadMarker, reloadFresh, FRESH_LOAD_PARAM } from './chunkReload'; +import { isChunkLoadError, reloadOnceForChunk, clearChunkReloadMarker, reloadFresh, importChunk, FRESH_LOAD_PARAM } from './chunkReload'; const net = vi.hoisted(() => ({ probeNow: vi.fn(async () => 'online' as 'online' | 'offline' | 'proxy-wall'), @@ -83,6 +83,18 @@ describe('reloadFresh', () => { describe('reloadOnceForChunk', () => { const stubReload = () => stubLocation().reload; + it('FE-UTIL-CHUNK-020: importChunk reloads once for a chunk the deploy removed, and still hands the caller the error', async () => { + const reload = stubReload(); + const gone = new TypeError('Failed to fetch dynamically imported module: /assets/TripPDF-abc123.js'); + await expect(importChunk(() => Promise.reject(gone))).rejects.toBe(gone); + expect(reload).toHaveBeenCalledTimes(1); + + // Anything else is the caller's error alone. + await expect(importChunk(() => Promise.reject(new Error('pdf layout failed')))).rejects.toThrow('pdf layout failed'); + expect(reload).toHaveBeenCalledTimes(1); + await expect(importChunk(() => Promise.resolve({ ok: true }))).resolves.toEqual({ ok: true }); + }); + it('FE-UTIL-CHUNK-004: reloads the first time and refuses afterwards', () => { const reload = stubReload(); expect(reloadOnceForChunk()).toBe(true); diff --git a/client/src/utils/chunkReload.ts b/client/src/utils/chunkReload.ts index 35e646fea..7afb93258 100644 --- a/client/src/utils/chunkReload.ts +++ b/client/src/utils/chunkReload.ts @@ -116,3 +116,19 @@ export function clearChunkReloadMarker(): void { // Nothing to clear if storage is unavailable. } } + +/** + * A dynamic import whose failure the caller catches. After a deploy the old + * page asks for chunk names the new build no longer has; a caller that only + * shows its own error would show it on every retry, so a missing chunk reloads + * onto the current build once, the way an uncaught one already does. The + * error is rethrown for the caller either way. + */ +export async function importChunk(load: () => Promise): Promise { + try { + return await load() + } catch (err) { + if (isChunkLoadError(err)) reloadOnceForChunk() + throw err + } +}