From c3c21fb0b404f0514b2b22b39645710a691ad3de Mon Sep 17 00:00:00 2001 From: Maurice Date: Wed, 7 Oct 2026 17:05:36 +0200 Subject: [PATCH] fix(client): five loads that catch their own error recover from a deploy too The PDF export on desktop and phone, the language switch, the holiday regions and the PDF's map image load a chunk on demand and catch the failure themselves. After a deploy the old page asks for chunk names the new build no longer has, so they showed their own error on every retry (or, for the language, silently stayed in the old one) instead of reloading onto the new build the way an uncaught chunk error does. importChunk reloads once for a missing chunk and still hands the caller its error. --- client/src/components/PDF/tripMapImage.ts | 3 ++- .../src/components/Planner/TripExportModal.tsx | 3 ++- client/src/components/Vacay/holidayRegions.ts | 3 ++- client/src/i18n/TranslationContext.tsx | 3 ++- .../mobile/screens/trip/sheets/MExportSheet.tsx | 3 ++- client/src/utils/chunkReload.test.ts | 14 +++++++++++++- client/src/utils/chunkReload.ts | 16 ++++++++++++++++ 7 files changed, 39 insertions(+), 6 deletions(-) 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 + } +}