From 0b78379b9be359e2b1e041b9d4b12f5197a10f02 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 04:34:35 +0000 Subject: [PATCH] =?UTF-8?q?Workers=20P2:=20Fehlergrenze=20um=20die=20Route?= =?UTF-8?q?n,=20damit=20ein=20fehlender=20lazy-Chunk=20nicht=20die=20ganze?= =?UTF-8?q?=20Oberfl=C3=A4che=20leert?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ein alter Tab nach einem Deploy fordert den Seiten-Chunk mit altem Hash an, nginx liefert die index.html, der Import schlägt fehl und React räumte ohne Boundary den gesamten Baum ab. RouteErrorBoundary hält Menü und Rahmen, zeigt den Hinweis mit Knopf „Seite neu laden“ und räumt den Fehler beim Pfadwechsel weg. Co-Authored-By: Claude Sonnet 5.5 --- src/App.jsx | 4 +- src/components/RouteErrorBoundary.jsx | 42 +++++++++++++++++ tests/unit/routeErrorBoundary.test.mjs | 65 ++++++++++++++++++++++++++ tests/unit/workersRoute.test.mjs | 9 ++++ 4 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 src/components/RouteErrorBoundary.jsx create mode 100644 tests/unit/routeErrorBoundary.test.mjs diff --git a/src/App.jsx b/src/App.jsx index 839cd7c..72384f3 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -23,6 +23,7 @@ import ServicesPage from './pages/ServicesPage' import ServerpulsPage from './pages/ServerpulsPage' import BankCallbackPage from './pages/BankCallbackPage' import AdminRoute from './components/AdminRoute' +import RouteErrorBoundary from './components/RouteErrorBoundary' import { safeReturnPath } from './lib/returnPath' // Hochhaus der Hustler: erst beim Öffnen laden, damit Engine und Schrift nicht im Haupt-Bundle liegen @@ -124,7 +125,8 @@ function AppContent() { WEBklar -
+ {/* Fängt u. a. einen fehlenden lazy-Chunk ab (alter Tab nach einem Deploy): Menü bleibt, Knopf lädt neu */} +
) diff --git a/src/components/RouteErrorBoundary.jsx b/src/components/RouteErrorBoundary.jsx new file mode 100644 index 0000000..5a1f5a0 --- /dev/null +++ b/src/components/RouteErrorBoundary.jsx @@ -0,0 +1,42 @@ +import { Component } from 'react' +import EmptyState from './ui/EmptyState' +import Button from './ui/Button' + +// Fängt Fehler beim Aufbau einer Seite ab, damit nicht die ganze Oberfläche verschwindet. +// Wichtigster Fall: Nach einem Deploy fehlt einem alten, noch offenen Tab der Chunk einer lazy geladenen Seite +// (nginx antwortet statt mit der .js-Datei mit der index.html, der Import schlägt fehl). Ohne Boundary räumt React +// dann den gesamten Baum ab (leere Seite); so bleiben Menü und Rahmen stehen und ein Knopf lädt neu. +// resetKey ist der Pfad: ein Seitenwechsel im Menü räumt den Fehler weg. Ein fehlgeschlagener lazy-Import bleibt +// dagegen fehlgeschlagen, bis die Seite neu geladen wird (React merkt sich die abgelehnte Zusage). +export default class RouteErrorBoundary extends Component { + constructor(props) { + super(props) + this.state = { error: null, resetKey: props.resetKey } + } + + static getDerivedStateFromError(error) { + return { error } + } + + static getDerivedStateFromProps(props, state) { + if (props.resetKey !== state.resetKey) return { error: null, resetKey: props.resetKey } + return null + } + + componentDidCatch(error) { + console.error('Seitenfehler:', error) + } + + render() { + if (!this.state.error) return this.props.children + return ( +
+ window.location.reload()}>Seite neu laden} + /> +
+ ) + } +} diff --git a/tests/unit/routeErrorBoundary.test.mjs b/tests/unit/routeErrorBoundary.test.mjs new file mode 100644 index 0000000..b2cf418 --- /dev/null +++ b/tests/unit/routeErrorBoundary.test.mjs @@ -0,0 +1,65 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import esbuild from 'esbuild' + +// Die Boundary ist JSX mit React: gebündelt wird mit eigenem automatischem JSX-Laufzeitpaket (load.mjs kennt kein JSX). +// Gezeichnet wird mit renderToStaticMarkup; der Fehlerzustand kommt über eine Unterklasse, die ihn vorab setzt +// (der Server-Renderer selbst fängt keine Fehler in Boundaries ab). +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..') + +async function load() { + const result = await esbuild.build({ + stdin: { + contents: [ + "import { createElement } from 'react'", + "import { renderToStaticMarkup } from 'react-dom/server'", + "import Boundary from './src/components/RouteErrorBoundary.jsx'", + 'export { createElement, renderToStaticMarkup, Boundary }', + ].join('\n'), + resolveDir: repoRoot, + loader: 'js', + }, + bundle: true, + format: 'esm', + platform: 'browser', + jsx: 'automatic', + define: { 'process.env.NODE_ENV': '"production"' }, + write: false, + }) + const code = result.outputFiles[0].text + return import(`data:text/javascript;base64,${Buffer.from(code).toString('base64')}`) +} + +test('RouteErrorBoundary: ohne Fehler bleiben die Kinder unverändert', async () => { + const { createElement, renderToStaticMarkup, Boundary } = await load() + const html = renderToStaticMarkup(createElement(Boundary, { resetKey: '/hustler' }, createElement('p', null, 'Inhalt'))) + assert.equal(html, '

Inhalt

') +}) + +test('RouteErrorBoundary: im Fehlerfall Hinweis mit Knopf „Seite neu laden“ statt der Kinder, als Alarm ausgezeichnet', async () => { + const { createElement, renderToStaticMarkup, Boundary } = await load() + class Probe extends Boundary { + constructor(props) { + super(props) + this.state = { error: new TypeError('Failed to fetch dynamically imported module'), resetKey: props.resetKey } + } + } + const html = renderToStaticMarkup(createElement(Probe, { resetKey: '/hustler' }, createElement('p', null, 'Inhalt'))) + assert.doesNotMatch(html, /Inhalt/) + assert.match(html, /role="alert"/) + assert.match(html, /Diese Seite konnte nicht angezeigt werden\./) + assert.match(html, /]*type="button"[^>]*>Seite neu laden<\/button>/) + assert.match(html, /neue Version von WOMS/) +}) + +test('RouteErrorBoundary: getDerivedStateFromError merkt den Fehler, ein Pfadwechsel räumt ihn weg, gleicher Pfad nicht', async () => { + const { Boundary } = await load() + const err = new TypeError('Failed to fetch dynamically imported module') + assert.deepEqual(Boundary.getDerivedStateFromError(err), { error: err }) + const stuck = { error: err, resetKey: '/hustler' } + assert.equal(Boundary.getDerivedStateFromProps({ resetKey: '/hustler' }, stuck), null) + assert.deepEqual(Boundary.getDerivedStateFromProps({ resetKey: '/tickets' }, stuck), { error: null, resetKey: '/tickets' }) + assert.deepEqual(Boundary.getDerivedStateFromProps({ resetKey: '/tickets' }, { error: null, resetKey: '/hustler' }), { error: null, resetKey: '/tickets' }) +}) diff --git a/tests/unit/workersRoute.test.mjs b/tests/unit/workersRoute.test.mjs index 5563b30..e8fb8b3 100644 --- a/tests/unit/workersRoute.test.mjs +++ b/tests/unit/workersRoute.test.mjs @@ -17,3 +17,12 @@ test('WorkersPage: Titel Hustler (H1 und Tab-Titel), Reiter hochhaus (kein Platz assert.match(s, /id: 'hochhaus'/); assert.doesNotMatch(s, /id: 'freigabe'/); assert.match(s, /WORKERS_TABS\.length > 1/) assert.match(s, /const api = workersApi/); assert.match(s, /pickTab\(/); assert.match(s, /isAdmin=\{isAdmin\}/) }) +test('App: RouteErrorBoundary um die Routen im Inhaltsbereich (fehlender lazy-Chunk in altem Tab lässt nicht die ganze Oberfläche verschwinden), Pfad als resetKey', () => { + const s = readFileSync(new URL('../../src/App.jsx', import.meta.url), 'utf8') + assert.match(s, /import RouteErrorBoundary from '\.\/components\/RouteErrorBoundary'/) + assert.match(s, /
<\/RouteErrorBoundary><\/main>/) + // das Menü (Sidebar) steht außerhalb der Boundary und bleibt bei einem Fehler benutzbar + assert.ok(s.indexOf('