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.
This commit is contained in:
Maurice
2026-10-07 17:05:36 +02:00
parent 0a1a5092f7
commit c3c21fb0b4
7 changed files with 39 additions and 6 deletions
+2 -1
View File
@@ -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
@@ -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) {
@@ -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<string, string>() // ISO code -> display name
const sub = iso31662.country(country)?.sub || {}
+2 -1
View File
@@ -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 —
@@ -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,
+13 -1
View File
@@ -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);
+16
View File
@@ -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<T>(load: () => Promise<T>): Promise<T> {
try {
return await load()
} catch (err) {
if (isChunkLoadError(err)) reloadOnceForChunk()
throw err
}
}