From 38b02608154357972fad04e07d65e7ea5c907788 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Apr 2026 21:54:00 +0000 Subject: [PATCH 1/7] feat: add custom 404/403/500 Inertia error pages Users previously saw raw JSON or a redirect to / when hitting errors. Now all HTTP 404, 403, and 500 errors render a proper React error page via Inertia with status code, title, description, and navigation buttons. - Add Error.tsx React component in host/client_app/pages/ - Extend page resolver to discover host-level pages (./pages/*.tsx) - Register exception handlers for HTTPException, NotFoundError, and unhandled exceptions in the app builder https://claude.ai/code/session_01TcCi9nT9jkWrXfByuqzDVs --- .../src/simple_module_hosting/app_builder.py | 48 ++++++++++++++++++ host/client_app/pages.ts | 16 +++++- host/client_app/pages/Error.tsx | 49 +++++++++++++++++++ 3 files changed, 111 insertions(+), 2 deletions(-) create mode 100644 host/client_app/pages/Error.tsx diff --git a/framework/hosting/src/simple_module_hosting/app_builder.py b/framework/hosting/src/simple_module_hosting/app_builder.py index 9f5ba5b0..8ffeabe5 100644 --- a/framework/hosting/src/simple_module_hosting/app_builder.py +++ b/framework/hosting/src/simple_module_hosting/app_builder.py @@ -8,12 +8,15 @@ from contextlib import asynccontextmanager from fastapi import APIRouter, FastAPI +from fastapi.exceptions import HTTPException from fastapi.staticfiles import StaticFiles from inertia import ( + Inertia, InertiaConfig, InertiaVersionConflictException, inertia_version_conflict_exception_handler, ) +from simple_module_core.exceptions import NotFoundError from simple_module_core.diagnostics import ( Diagnostic, DiagnosticLevel, @@ -29,6 +32,8 @@ from simple_module_db.listeners import register_listeners from simple_module_db.session import init_db from starlette.middleware.sessions import SessionMiddleware +from starlette.requests import Request +from starlette.responses import Response from simple_module_hosting.health import router as health_router from simple_module_hosting.middleware import InertiaLayoutDataMiddleware, SecurityHeadersMiddleware @@ -193,6 +198,9 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: InertiaVersionConflictException, inertia_version_conflict_exception_handler, # ty: ignore[invalid-argument-type] ) + app.add_exception_handler(HTTPException, _http_exception_handler) # ty: ignore[invalid-argument-type] + app.add_exception_handler(NotFoundError, _not_found_error_handler) # ty: ignore[invalid-argument-type] + app.add_exception_handler(Exception, _unhandled_exception_handler) # ty: ignore[invalid-argument-type] for mod in modules: mod.register_exception_handlers(app) @@ -232,6 +240,46 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: return app +async def _render_error_page(request: Request, status_code: int, message: str) -> Response: + """Render the Inertia error page for the given status code.""" + config: InertiaConfig = request.app.state.inertia_config + try: + inertia = Inertia(request, config) + except InertiaVersionConflictException: + return await inertia_version_conflict_exception_handler( + request, InertiaVersionConflictException(url=str(request.url)) + ) + + response = await inertia.render("Error", {"status": status_code, "message": message}) + response.status_code = status_code + return response + + +async def _http_exception_handler(request: Request, exc: HTTPException) -> Response: + """Handle HTTP exceptions (404, 403, etc.) with Inertia error pages.""" + status_code = exc.status_code + if status_code in (403, 404, 500): + return await _render_error_page(request, status_code, exc.detail or "") + # For other HTTP errors, return a plain JSON response + from fastapi.responses import JSONResponse + + return JSONResponse( + status_code=status_code, + content={"detail": exc.detail}, + ) + + +async def _not_found_error_handler(request: Request, exc: NotFoundError) -> Response: + """Handle framework NotFoundError with a 404 Inertia error page.""" + return await _render_error_page(request, 404, str(exc)) + + +async def _unhandled_exception_handler(request: Request, exc: Exception) -> Response: + """Handle uncaught exceptions with a 500 Inertia error page.""" + logger.exception("Unhandled exception: %s", exc) + return await _render_error_page(request, 500, "") + + def _setup_inertia(app: FastAPI, settings: Settings) -> None: """Configure fastapi-inertia with the Jinja2 template.""" import os diff --git a/host/client_app/pages.ts b/host/client_app/pages.ts index 9d9a5151..fb9520e6 100644 --- a/host/client_app/pages.ts +++ b/host/client_app/pages.ts @@ -4,6 +4,9 @@ * Convention: modules/{name}/src/sm_{name}/pages/{PageName}.tsx * Inertia component name: "{ModuleName}/{PageName}" * + * Host-level pages live in host/client_app/pages/{PageName}.tsx + * and are registered as just "{PageName}" (e.g., "Error"). + * * Vite code-splits each page into its own chunk automatically. * HMR works instantly — just edit any .tsx file. */ @@ -11,11 +14,12 @@ type PageModule = { default: React.ComponentType> }; type PageLoader = () => Promise; -const pageModules = import.meta.glob('../../modules/*/src/*/pages/*.tsx'); +const modulePages = import.meta.glob('../../modules/*/src/*/pages/*.tsx'); +const hostPages = import.meta.glob('./pages/*.tsx'); const pages: Record = {}; -for (const [filePath, loader] of Object.entries(pageModules)) { +for (const [filePath, loader] of Object.entries(modulePages)) { // Extract module name and page name from file path // e.g., "../../modules/products/src/sm_products/pages/Browse.tsx" // -> moduleName = "Products", pageName = "Browse" @@ -27,6 +31,14 @@ for (const [filePath, loader] of Object.entries(pageModules)) { } } +for (const [filePath, loader] of Object.entries(hostPages)) { + // e.g., "./pages/Error.tsx" -> "Error" + const match = filePath.match(/\.\/pages\/(\w+)\.tsx$/); + if (match) { + pages[match[1]] = loader; + } +} + export async function resolvePage( name: string, ): Promise>> { diff --git a/host/client_app/pages/Error.tsx b/host/client_app/pages/Error.tsx new file mode 100644 index 00000000..be31b092 --- /dev/null +++ b/host/client_app/pages/Error.tsx @@ -0,0 +1,49 @@ +import { Link } from '@inertiajs/react'; + +interface Props { + status: number; + message: string; +} + +const titles: Record = { + 403: 'Forbidden', + 404: 'Page Not Found', + 500: 'Server Error', +}; + +const descriptions: Record = { + 403: "You don't have permission to access this page.", + 404: "The page you're looking for doesn't exist or has been moved.", + 500: 'Something went wrong on our end. Please try again later.', +}; + +function Error({ status, message }: Props) { + const title = titles[status] || 'Error'; + const description = message || descriptions[status] || 'An unexpected error occurred.'; + + return ( +
+
+

{status}

+

+ {title} +

+

{description}

+
+ + Go Home + + +
+
+
+ ); +} + +export default Error; From eb3157a20fb1bb5f0cb382f97bc64564653f73b6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Apr 2026 22:04:42 +0000 Subject: [PATCH 2/7] refactor: simplify error page handlers from review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reuse caught InertiaVersionConflictException instead of constructing new one - Move JSONResponse import to module level - Remove unnecessary docstrings on private helpers - Extract _INERTIA_ERROR_STATUSES constant for the magic status set - Use str(exc.detail) for type-safe detail conversion - Rename Error → ErrorPage to avoid shadowing window.Error - Add defensive JSONResponse fallback if Inertia rendering itself fails https://claude.ai/code/session_01TcCi9nT9jkWrXfByuqzDVs --- .../src/simple_module_hosting/app_builder.py | 38 ++++++++----------- host/client_app/pages/Error.tsx | 4 +- 2 files changed, 18 insertions(+), 24 deletions(-) diff --git a/framework/hosting/src/simple_module_hosting/app_builder.py b/framework/hosting/src/simple_module_hosting/app_builder.py index cad874a6..f6403db1 100644 --- a/framework/hosting/src/simple_module_hosting/app_builder.py +++ b/framework/hosting/src/simple_module_hosting/app_builder.py @@ -9,6 +9,7 @@ from fastapi import APIRouter, FastAPI from fastapi.exceptions import HTTPException +from fastapi.responses import JSONResponse from fastapi.staticfiles import StaticFiles from inertia import ( Inertia, @@ -240,42 +241,35 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: return app +_INERTIA_ERROR_STATUSES = frozenset({403, 404, 500}) + + async def _render_error_page(request: Request, status_code: int, message: str) -> Response: - """Render the Inertia error page for the given status code.""" config: InertiaConfig = request.app.state.inertia_config try: inertia = Inertia(request, config) - except InertiaVersionConflictException: - return await inertia_version_conflict_exception_handler( - request, InertiaVersionConflictException(url=str(request.url)) - ) - - response = await inertia.render("Error", {"status": status_code, "message": message}) - response.status_code = status_code - return response + response = await inertia.render("Error", {"status": status_code, "message": message}) + response.status_code = status_code + return response + except InertiaVersionConflictException as exc: + return await inertia_version_conflict_exception_handler(request, exc) + except Exception: + # Fallback if Inertia rendering itself fails (e.g. missing session) + return JSONResponse(status_code=status_code, content={"detail": message or "Internal Server Error"}) async def _http_exception_handler(request: Request, exc: HTTPException) -> Response: - """Handle HTTP exceptions (404, 403, etc.) with Inertia error pages.""" - status_code = exc.status_code - if status_code in (403, 404, 500): - return await _render_error_page(request, status_code, exc.detail or "") - # For other HTTP errors, return a plain JSON response - from fastapi.responses import JSONResponse - - return JSONResponse( - status_code=status_code, - content={"detail": exc.detail}, - ) + if exc.status_code in _INERTIA_ERROR_STATUSES: + detail = str(exc.detail) if exc.detail else "" + return await _render_error_page(request, exc.status_code, detail) + return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail}) async def _not_found_error_handler(request: Request, exc: NotFoundError) -> Response: - """Handle framework NotFoundError with a 404 Inertia error page.""" return await _render_error_page(request, 404, str(exc)) async def _unhandled_exception_handler(request: Request, exc: Exception) -> Response: - """Handle uncaught exceptions with a 500 Inertia error page.""" logger.exception("Unhandled exception: %s", exc) return await _render_error_page(request, 500, "") diff --git a/host/client_app/pages/Error.tsx b/host/client_app/pages/Error.tsx index f831b5a9..36cfccfb 100644 --- a/host/client_app/pages/Error.tsx +++ b/host/client_app/pages/Error.tsx @@ -18,7 +18,7 @@ const descriptions: Record = { 500: 'Something went wrong on our end. Please try again later.', }; -function Error({ status, message }: Props) { +function ErrorPage({ status, message }: Props) { const title = titles[status] || 'Error'; const description = message || descriptions[status] || 'An unexpected error occurred.'; @@ -43,4 +43,4 @@ function Error({ status, message }: Props) { ); } -export default Error; +export default ErrorPage; From 84efd1ab7a66ae400710012d92bb4de6e4e024f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Apr 2026 08:03:13 +0000 Subject: [PATCH 3/7] chore: log fallback when Inertia error rendering fails Adds observability when _render_error_page falls back to JSONResponse, so failures in the error-rendering path itself can be debugged rather than silently swallowed. https://claude.ai/code/session_01TcCi9nT9jkWrXfByuqzDVs --- framework/hosting/src/simple_module_hosting/app_builder.py | 1 + 1 file changed, 1 insertion(+) diff --git a/framework/hosting/src/simple_module_hosting/app_builder.py b/framework/hosting/src/simple_module_hosting/app_builder.py index f6403db1..52ff9559 100644 --- a/framework/hosting/src/simple_module_hosting/app_builder.py +++ b/framework/hosting/src/simple_module_hosting/app_builder.py @@ -255,6 +255,7 @@ async def _render_error_page(request: Request, status_code: int, message: str) - return await inertia_version_conflict_exception_handler(request, exc) except Exception: # Fallback if Inertia rendering itself fails (e.g. missing session) + logger.exception("Error page rendering failed, falling back to JSON") return JSONResponse(status_code=status_code, content={"detail": message or "Internal Server Error"}) From 57ca88d66b182e23abdb8cde5a6aa632cf588724 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Apr 2026 08:08:28 +0000 Subject: [PATCH 4/7] fix: catch Starlette HTTPException so router 404s hit our handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FastAPI's HTTPException subclasses Starlette's. Registering a handler for the FastAPI class doesn't catch exceptions raised as the Starlette base class — Starlette's router itself raises starlette.exceptions.HTTPException for unmatched routes, which never hit our handler and fell through to the default JSON response. Register against starlette.exceptions.HTTPException instead. FastAPI's subclass is still caught because the MRO walks up to the parent. Verified by running the app and hitting a non-existent route: now returns the Inertia Error page with status 404 instead of a raw {"detail": "Not Found"} JSON body. https://claude.ai/code/session_01TcCi9nT9jkWrXfByuqzDVs --- framework/hosting/simple_module_hosting/app_builder.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/framework/hosting/simple_module_hosting/app_builder.py b/framework/hosting/simple_module_hosting/app_builder.py index baa0940a..eae5efd4 100644 --- a/framework/hosting/simple_module_hosting/app_builder.py +++ b/framework/hosting/simple_module_hosting/app_builder.py @@ -8,9 +8,9 @@ from pathlib import Path from fastapi import APIRouter, FastAPI -from fastapi.exceptions import HTTPException from fastapi.responses import JSONResponse from fastapi.staticfiles import StaticFiles +from starlette.exceptions import HTTPException from inertia import ( Inertia, InertiaConfig, From 9441c25bf17b66b0e6d4dc256a9aab7177e1311c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Apr 2026 08:49:01 +0000 Subject: [PATCH 5/7] feat: add React error boundary to catch client-side errors Complements the server-rendered error pages: whenever a React component throws during render, the ErrorBoundary shows a friendly fallback UI (stack trace in dev mode only) with Reload and Go Home actions, instead of crashing the whole app to a blank screen. - Add ErrorBoundary class component in packages/ui with onError callback and customizable fallback props - Wrap the Inertia App in host/client_app/app.tsx - Reset the boundary on Inertia navigate events so the next page can render after a recovered error https://claude.ai/code/session_01TcCi9nT9jkWrXfByuqzDVs --- host/client_app/app.tsx | 21 ++++- packages/ui/src/components/ErrorBoundary.tsx | 83 ++++++++++++++++++++ packages/ui/src/index.ts | 1 + 3 files changed, 103 insertions(+), 2 deletions(-) create mode 100644 packages/ui/src/components/ErrorBoundary.tsx diff --git a/host/client_app/app.tsx b/host/client_app/app.tsx index 7a181f6a..2c4cea2e 100644 --- a/host/client_app/app.tsx +++ b/host/client_app/app.tsx @@ -1,4 +1,6 @@ -import { createInertiaApp } from '@inertiajs/react'; +import { createInertiaApp, router } from '@inertiajs/react'; +import { ErrorBoundary } from '@ui/components/ErrorBoundary'; +import { useEffect, useRef } from 'react'; import { createRoot } from 'react-dom/client'; import { resolvePage } from './pages'; @@ -8,7 +10,22 @@ createInertiaApp({ return page; }, setup({ el, App, props }) { - createRoot(el).render(); + function Root() { + const boundaryRef = useRef(null); + + useEffect(() => { + // Reset the boundary on navigation so the next page can render. + return router.on('navigate', () => boundaryRef.current?.reset()); + }, []); + + return ( + + + + ); + } + + createRoot(el).render(); }, progress: { color: '#4B5563', diff --git a/packages/ui/src/components/ErrorBoundary.tsx b/packages/ui/src/components/ErrorBoundary.tsx new file mode 100644 index 00000000..3036fc14 --- /dev/null +++ b/packages/ui/src/components/ErrorBoundary.tsx @@ -0,0 +1,83 @@ +import { Button } from '@ui/components/ui/button'; +import { Component, type ErrorInfo, type ReactNode } from 'react'; + +interface Props { + children: ReactNode; + /** Callback fired when an error is caught. Useful for error tracking. */ + onError?: (error: Error, info: ErrorInfo) => void; + /** Optional custom fallback UI. Receives the error and a reset function. */ + fallback?: (error: Error, reset: () => void) => ReactNode; +} + +interface State { + error: Error | null; +} + +export class ErrorBoundary extends Component { + state: State = { error: null }; + + static getDerivedStateFromError(error: Error): State { + return { error }; + } + + componentDidCatch(error: Error, info: ErrorInfo): void { + this.props.onError?.(error, info); + if (import.meta.env.DEV) { + console.error('ErrorBoundary caught:', error, info); + } + } + + reset = (): void => { + this.setState({ error: null }); + }; + + render(): ReactNode { + const { error } = this.state; + if (error === null) return this.props.children; + + if (this.props.fallback) return this.props.fallback(error, this.reset); + + return ; + } +} + +function DefaultFallback({ error, reset }: { error: Error; reset: () => void }) { + return ( +
+
+

!

+

+ Something went wrong +

+

+ The page encountered an unexpected error. Try reloading, or return home. +

+ {import.meta.env.DEV && ( +
+            {error.message}
+            {error.stack ? `\n\n${error.stack}` : ''}
+          
+ )} +
+ + +
+
+
+ ); +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index f8fa7ab3..ed4f334e 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -1,3 +1,4 @@ +export { ErrorBoundary } from './components/ErrorBoundary'; export { NavIcon } from './components/NavIcon'; export { PageShell } from './components/PageShell'; export { AdminLayout } from './layouts/AdminLayout'; From d5cb6f7899537c244976109b8b04766519c3151b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Apr 2026 08:49:40 +0000 Subject: [PATCH 6/7] chore: sync package-lock.json and local settings - package-lock.json: regenerated after npm install during error boundary work - .claude/settings.local.json: updated tool permissions from session https://claude.ai/code/session_01TcCi9nT9jkWrXfByuqzDVs --- .claude/settings.local.json | 4 +- package-lock.json | 124 +++++++++++++++++++++++------------- 2 files changed, 83 insertions(+), 45 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index c0fea6b2..8b989358 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -4,7 +4,9 @@ "mcp__plugin_playwright_playwright__browser_navigate", "mcp__plugin_playwright_playwright__browser_take_screenshot", "mcp__plugin_playwright_playwright__browser_evaluate", - "Skill(playwright-cli)" + "Skill(playwright-cli)", + "mcp__plugin_context7_context7__query-docs", + "mcp__plugin_context7_context7__resolve-library-id" ] } } diff --git a/package-lock.json b/package-lock.json index 0ca68b48..8cd853c8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -283,8 +283,7 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.4.1.tgz", "integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", @@ -299,6 +298,7 @@ "os": [ "aix" ], + "peer": true, "engines": { "node": ">=18" } @@ -316,6 +316,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -333,6 +334,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -350,6 +352,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -367,6 +370,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=18" } @@ -384,6 +388,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=18" } @@ -401,6 +406,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -418,6 +424,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -435,6 +442,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -452,6 +460,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -469,6 +478,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -486,6 +496,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -503,6 +514,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -520,6 +532,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -537,6 +550,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -554,6 +568,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -571,6 +586,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -588,6 +604,7 @@ "os": [ "netbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -605,6 +622,7 @@ "os": [ "netbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -622,6 +640,7 @@ "os": [ "openbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -639,6 +658,7 @@ "os": [ "openbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -656,6 +676,7 @@ "os": [ "openharmony" ], + "peer": true, "engines": { "node": ">=18" } @@ -673,6 +694,7 @@ "os": [ "sunos" ], + "peer": true, "engines": { "node": ">=18" } @@ -690,6 +712,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -707,6 +730,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -724,6 +748,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -4002,7 +4027,8 @@ "optional": true, "os": [ "android" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-android-arm64": { "version": "4.60.1", @@ -4016,7 +4042,8 @@ "optional": true, "os": [ "android" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-darwin-arm64": { "version": "4.60.1", @@ -4030,7 +4057,8 @@ "optional": true, "os": [ "darwin" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-darwin-x64": { "version": "4.60.1", @@ -4044,7 +4072,8 @@ "optional": true, "os": [ "darwin" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-freebsd-arm64": { "version": "4.60.1", @@ -4058,7 +4087,8 @@ "optional": true, "os": [ "freebsd" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-freebsd-x64": { "version": "4.60.1", @@ -4072,7 +4102,8 @@ "optional": true, "os": [ "freebsd" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { "version": "4.60.1", @@ -4086,7 +4117,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { "version": "4.60.1", @@ -4100,7 +4132,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm64-gnu": { "version": "4.60.1", @@ -4114,7 +4147,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm64-musl": { "version": "4.60.1", @@ -4128,7 +4162,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-loong64-gnu": { "version": "4.60.1", @@ -4142,7 +4177,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-loong64-musl": { "version": "4.60.1", @@ -4156,7 +4192,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { "version": "4.60.1", @@ -4170,7 +4207,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-ppc64-musl": { "version": "4.60.1", @@ -4184,7 +4222,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { "version": "4.60.1", @@ -4198,7 +4237,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-riscv64-musl": { "version": "4.60.1", @@ -4212,7 +4252,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-s390x-gnu": { "version": "4.60.1", @@ -4226,7 +4267,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-x64-gnu": { "version": "4.60.1", @@ -4240,7 +4282,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-x64-musl": { "version": "4.60.1", @@ -4254,7 +4297,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-openbsd-x64": { "version": "4.60.1", @@ -4268,7 +4312,8 @@ "optional": true, "os": [ "openbsd" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-openharmony-arm64": { "version": "4.60.1", @@ -4282,7 +4327,8 @@ "optional": true, "os": [ "openharmony" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-win32-arm64-msvc": { "version": "4.60.1", @@ -4296,7 +4342,8 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-win32-ia32-msvc": { "version": "4.60.1", @@ -4310,7 +4357,8 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-win32-x64-gnu": { "version": "4.60.1", @@ -4324,7 +4372,8 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-win32-x64-msvc": { "version": "4.60.1", @@ -4338,7 +4387,8 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@simple-module/ui": { "resolved": "packages/ui", @@ -5000,7 +5050,6 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -5011,7 +5060,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -5145,7 +5193,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -5392,7 +5439,6 @@ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/kossnocorp" @@ -5460,8 +5506,7 @@ "version": "8.6.0", "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/embla-carousel-react": { "version": "8.6.0", @@ -5691,6 +5736,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } @@ -5843,7 +5889,6 @@ "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "jiti": "lib/jiti-cli.mjs" } @@ -5864,7 +5909,6 @@ "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", "dev": true, "license": "MPL-2.0", - "peer": true, "dependencies": { "detect-libc": "^2.0.3" }, @@ -6236,7 +6280,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -6264,7 +6307,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -6428,7 +6470,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -6460,7 +6501,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -6473,7 +6513,6 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.72.1.tgz", "integrity": "sha512-RhwBoy2ygeVZje+C+bwJ8g0NjTdBmDlJvAUHTxRjTmSUKPYsKfMphkS2sgEMotsY03bP358yEYlnUeZy//D9Ig==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -6497,7 +6536,6 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", - "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -6629,8 +6667,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -6999,7 +7036,6 @@ "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", From 836778952defacff177599bed42a0345255f9840 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Apr 2026 08:57:24 +0000 Subject: [PATCH 7/7] refactor: extract shared ErrorScreen component from review findings Unifies the near-identical layout used by the server-side Error page and the client-side ErrorBoundary fallback into a single ErrorScreen primitive with hero/title/description/details/actions slots. Other fixes from the review: - Guard reset() so it no-ops when error is already null, avoiding redundant setState on every successful navigation - Drop pre-unload reset() calls before window.location.reload/redirect; the whole React tree is about to be discarded - Tighten stack trace formatting with filter(Boolean).join('\n\n') https://claude.ai/code/session_01TcCi9nT9jkWrXfByuqzDVs --- host/client_app/pages/Error.tsx | 26 +++---- packages/ui/src/components/ErrorBoundary.tsx | 71 ++++++++------------ packages/ui/src/components/ErrorScreen.tsx | 25 +++++++ packages/ui/src/index.ts | 1 + 4 files changed, 63 insertions(+), 60 deletions(-) create mode 100644 packages/ui/src/components/ErrorScreen.tsx diff --git a/host/client_app/pages/Error.tsx b/host/client_app/pages/Error.tsx index 36cfccfb..3a39d778 100644 --- a/host/client_app/pages/Error.tsx +++ b/host/client_app/pages/Error.tsx @@ -1,4 +1,5 @@ import { Link } from '@inertiajs/react'; +import { ErrorScreen } from '@ui/components/ErrorScreen'; import { Button } from '@ui/components/ui/button'; interface Props { @@ -23,23 +24,14 @@ function ErrorPage({ status, message }: Props) { const description = message || descriptions[status] || 'An unexpected error occurred.'; return ( -
-
-

{status}

-

- {title} -

-

{description}

-
- - -
-
-
+ + + + ); } diff --git a/packages/ui/src/components/ErrorBoundary.tsx b/packages/ui/src/components/ErrorBoundary.tsx index 3036fc14..c7c1e5e5 100644 --- a/packages/ui/src/components/ErrorBoundary.tsx +++ b/packages/ui/src/components/ErrorBoundary.tsx @@ -1,11 +1,11 @@ +import { ErrorScreen } from '@ui/components/ErrorScreen'; import { Button } from '@ui/components/ui/button'; import { Component, type ErrorInfo, type ReactNode } from 'react'; interface Props { children: ReactNode; - /** Callback fired when an error is caught. Useful for error tracking. */ + /** Useful for error tracking (Sentry, etc). */ onError?: (error: Error, info: ErrorInfo) => void; - /** Optional custom fallback UI. Receives the error and a reset function. */ fallback?: (error: Error, reset: () => void) => ReactNode; } @@ -28,56 +28,41 @@ export class ErrorBoundary extends Component { } reset = (): void => { - this.setState({ error: null }); + if (this.state.error !== null) this.setState({ error: null }); }; render(): ReactNode { const { error } = this.state; if (error === null) return this.props.children; - if (this.props.fallback) return this.props.fallback(error, this.reset); - - return ; + return ; } } -function DefaultFallback({ error, reset }: { error: Error; reset: () => void }) { +function DefaultFallback({ error }: { error: Error }) { + const details = import.meta.env.DEV ? ( +
+      {[error.message, error.stack].filter(Boolean).join('\n\n')}
+    
+ ) : undefined; + return ( -
-
-

!

-

- Something went wrong -

-

- The page encountered an unexpected error. Try reloading, or return home. -

- {import.meta.env.DEV && ( -
-            {error.message}
-            {error.stack ? `\n\n${error.stack}` : ''}
-          
- )} -
- - -
-
-
+ + {/* Full reload — React tree is broken, Inertia navigation won't recover. */} + + + ); } diff --git a/packages/ui/src/components/ErrorScreen.tsx b/packages/ui/src/components/ErrorScreen.tsx new file mode 100644 index 00000000..79a6a547 --- /dev/null +++ b/packages/ui/src/components/ErrorScreen.tsx @@ -0,0 +1,25 @@ +import type { ReactNode } from 'react'; + +interface Props { + hero: ReactNode; + title: string; + description: string; + details?: ReactNode; + children: ReactNode; +} + +export function ErrorScreen({ hero, title, description, details, children }: Props) { + return ( +
+
+

{hero}

+

+ {title} +

+

{description}

+ {details} +
{children}
+
+
+ ); +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index ed4f334e..e4be644b 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -1,4 +1,5 @@ export { ErrorBoundary } from './components/ErrorBoundary'; +export { ErrorScreen } from './components/ErrorScreen'; export { NavIcon } from './components/NavIcon'; export { PageShell } from './components/PageShell'; export { AdminLayout } from './layouts/AdminLayout';