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('