From 040dd627351e73938c4e13d6728a3f0ba059c8ba Mon Sep 17 00:00:00 2001 From: DeeCee03 Date: Sat, 6 Jun 2026 17:15:01 +0200 Subject: [PATCH] fix(web): Stale-Chunk-Fehler nach Deploy automatisch abfangen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Failed to fetch dynamically imported module" trat auf, wenn ein offener Tab nach einem Deploy auf alte (gehashte, inzwischen gelöschte) Route-Chunks zeigt. Jetzt: - lazyWithReload() lädt bei fehlgeschlagenem Lazy-Import einmal automatisch neu (holt frische index.html); sessionStorage-Guard verhindert Reload-Schleifen. - vite:preloadError-Event wird ebenfalls abgefangen (modulepreload-Fehler). - errorElement (RouteError) ersetzt die Dev-Default-Fehlerseite durch eine saubere "Neu laden"-Ansicht als Fallback. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/web/src/lib/router.tsx | 83 +++++++++++++++++++++++++++++++------ 1 file changed, 71 insertions(+), 12 deletions(-) diff --git a/apps/web/src/lib/router.tsx b/apps/web/src/lib/router.tsx index 7f2778e..0969d1e 100644 --- a/apps/web/src/lib/router.tsx +++ b/apps/web/src/lib/router.tsx @@ -1,24 +1,67 @@ import { createBrowserRouter, RouterProvider } from 'react-router'; -import { lazy, Suspense } from 'react'; +import { lazy, Suspense, type ComponentType } from 'react'; import { AppShell } from '../components/layout/AppShell'; import { SkeletonCard } from '../components/ui/SkeletonCard'; +import { Button } from '../components/ui/Button'; -const Dashboard = lazy(() => import('../routes/dashboard').then((m) => ({ default: m.DashboardRoute }))); -const Heute = lazy(() => - import('../routes/heute').then((m) => ({ default: m.HeuteRoute })), +// Nach einem Deploy zeigen bereits offene Tabs auf alte, inzwischen gelöschte +// Chunk-Dateien (gehashte Dateinamen). Der nächste Lazy-Import schlägt dann fehl +// („Failed to fetch dynamically imported module"). Ein einmaliger Reload holt die +// frische index.html mit den neuen Hashes. sessionStorage-Guard verhindert eine +// Reload-Schleife, falls der Fehler eine andere Ursache hat (z. B. offline). +const RELOAD_KEY = 'ebw:chunk-reload'; + +function reloadOnceForStaleChunk(): boolean { + const last = Number(sessionStorage.getItem(RELOAD_KEY) ?? '0'); + if (Date.now() - last > 10_000) { + sessionStorage.setItem(RELOAD_KEY, String(Date.now())); + window.location.reload(); + return true; + } + return false; +} + +function lazyWithReload(factory: () => Promise<{ default: T }>) { + return lazy(() => + factory().catch((err: unknown) => { + if (reloadOnceForStaleChunk()) { + // Hängt bis der Reload greift — vermeidet ein kurzes Aufblitzen des Fehlers. + return new Promise<{ default: T }>(() => {}); + } + throw err; + }), + ); +} + +// Vite feuert dieses Event bei fehlgeschlagenem modulepreload (gleiche Ursache). +if (typeof window !== 'undefined') { + window.addEventListener('vite:preloadError', (e) => { + if (reloadOnceForStaleChunk()) e.preventDefault(); + }); +} + +const Dashboard = lazyWithReload(() => + import('../routes/dashboard').then((m) => ({ default: m.DashboardRoute })), ); -const Reflexion = lazy(() => +const Heute = lazyWithReload(() => import('../routes/heute').then((m) => ({ default: m.HeuteRoute }))); +const Reflexion = lazyWithReload(() => import('../routes/reflexion').then((m) => ({ default: m.ReflexionRoute })), ); -const Bereich = lazy(() => import('../routes/bereich').then((m) => ({ default: m.BereichRoute }))); -const Projekt = lazy(() => import('../routes/projekt').then((m) => ({ default: m.ProjektRoute }))); -const Leads = lazy(() => import('../routes/leads').then((m) => ({ default: m.LeadsRoute }))); -const Lead = lazy(() => import('../routes/lead').then((m) => ({ default: m.LeadRoute }))); -const Archiv = lazy(() => import('../routes/archiv').then((m) => ({ default: m.ArchivRoute }))); -const NotFound = lazy(() => +const Bereich = lazyWithReload(() => + import('../routes/bereich').then((m) => ({ default: m.BereichRoute })), +); +const Projekt = lazyWithReload(() => + import('../routes/projekt').then((m) => ({ default: m.ProjektRoute })), +); +const Leads = lazyWithReload(() => import('../routes/leads').then((m) => ({ default: m.LeadsRoute }))); +const Lead = lazyWithReload(() => import('../routes/lead').then((m) => ({ default: m.LeadRoute }))); +const Archiv = lazyWithReload(() => + import('../routes/archiv').then((m) => ({ default: m.ArchivRoute })), +); +const NotFound = lazyWithReload(() => import('../routes/not-found').then((m) => ({ default: m.NotFoundRoute })), ); -const Login = lazy(() => import('../routes/login').then((m) => ({ default: m.LoginRoute }))); +const Login = lazyWithReload(() => import('../routes/login').then((m) => ({ default: m.LoginRoute }))); function withSuspense(node: React.ReactNode) { return ( @@ -26,14 +69,30 @@ function withSuspense(node: React.ReactNode) { ); } +function RouteError() { + return ( +
+

Etwas ist schiefgelaufen.

+

+ Wahrscheinlich wurde gerade eine neue Version veröffentlicht. Lade die Seite neu. +

+ +
+ ); +} + const router = createBrowserRouter([ { path: '/login', element: withSuspense(), + errorElement: , }, { path: '/', element: , + errorElement: , children: [ { index: true, element: withSuspense() }, { path: 'dashboard', element: withSuspense() },