From 72641d4408b5a759643396711eaac6ee9b89516e Mon Sep 17 00:00:00 2001 From: BrandDead <1.74973198e+08+BrandDead@users.noreply.github.com> Date: Tue, 1 Sep 2026 21:50:49 +0000 Subject: [PATCH] fix(startup): show recovery state for bootstrap failures --- frontend/src/App.css | 82 +++++++++++++++++++ .../system/StartupErrorBoundary.test.tsx | 44 ++++++++++ .../system/StartupErrorBoundary.tsx | 66 +++++++++++++++ frontend/src/main.tsx | 22 +++-- 4 files changed, 205 insertions(+), 9 deletions(-) create mode 100644 frontend/src/components/system/StartupErrorBoundary.test.tsx create mode 100644 frontend/src/components/system/StartupErrorBoundary.tsx diff --git a/frontend/src/App.css b/frontend/src/App.css index 4ff3712..04d5878 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -211,3 +211,85 @@ body { 0%, 100% { opacity: 0.6; } 50% { opacity: 1; } } + +/* Bootstrap failure state — must remain usable before the App module loads. */ +.startup-error-screen { + min-height: 100%; + width: 100%; + display: grid; + place-items: center; + padding: 24px; + background: + radial-gradient(circle at 50% 0%, rgba(227, 38, 54, 0.18), transparent 42%), + linear-gradient(160deg, #0d0e14 0%, #050505 62%, #16080b 100%); +} + +.startup-error-card { + width: min(100%, 440px); + border: 1px solid rgba(255, 91, 91, 0.48); + border-radius: 16px; + padding: 32px 28px; + background: rgba(10, 10, 14, 0.95); + box-shadow: 0 20px 70px rgba(0, 0, 0, 0.54), 0 0 32px rgba(255, 44, 44, 0.12); + text-align: center; +} + +.startup-error-eyebrow { + display: block; + margin-bottom: 12px; + color: #ff7777; + font-family: 'Oswald', sans-serif; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.24em; +} + +.startup-error-card h1 { + margin: 0; + color: #ffffff; + font-family: 'Bebas Neue', 'Oswald', sans-serif; + font-size: clamp(30px, 8vw, 44px); + font-weight: 400; + letter-spacing: 0.055em; + line-height: 1; +} + +.startup-error-card p { + margin: 20px auto 0; + max-width: 34ch; + color: rgba(255, 255, 255, 0.78); + font-size: 16px; + line-height: 1.5; +} + +.startup-error-retry { + margin-top: 28px; + min-height: 44px; + border: 1px solid #ff6565; + border-radius: 8px; + padding: 0 20px; + background: rgba(222, 44, 44, 0.16); + color: #ffffff; + cursor: pointer; + font-family: 'Oswald', sans-serif; + font-size: 14px; + font-weight: 700; + letter-spacing: 0.12em; +} + +.startup-error-retry:hover, +.startup-error-retry:focus-visible { + outline: none; + background: rgba(222, 44, 44, 0.35); + box-shadow: 0 0 0 3px rgba(255, 111, 111, 0.25); +} + +@media (max-width: 360px) { + .startup-error-card { + padding: 28px 20px; + } + + .startup-error-card p { + font-size: 15px; + } +} diff --git a/frontend/src/components/system/StartupErrorBoundary.test.tsx b/frontend/src/components/system/StartupErrorBoundary.test.tsx new file mode 100644 index 0000000..5bbbca6 --- /dev/null +++ b/frontend/src/components/system/StartupErrorBoundary.test.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import StartupErrorBoundary from './StartupErrorBoundary'; + +function ThrowError({ message }: { message: string }): never { + throw new Error(message); +} + +describe('StartupErrorBoundary', () => { + let consoleError: ReturnType; + + beforeEach(() => { + consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); + }); + + afterEach(() => { + consoleError.mockRestore(); + }); + + it('explains missing Supabase configuration instead of leaving a blank application frame', () => { + render( + + + + ); + + expect(screen.getByRole('alert')).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: /system configuration required/i })).toBeInTheDocument(); + expect(screen.getByText(/missing a required service configuration/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /retry startup/i })).toBeInTheDocument(); + }); + + it('shows a neutral recovery state for unexpected bootstrap failures', () => { + render( + + + + ); + + expect(screen.getByRole('heading', { name: /system unavailable/i })).toBeInTheDocument(); + expect(screen.getByText(/could not finish starting/i)).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/system/StartupErrorBoundary.tsx b/frontend/src/components/system/StartupErrorBoundary.tsx new file mode 100644 index 0000000..ca0ab56 --- /dev/null +++ b/frontend/src/components/system/StartupErrorBoundary.tsx @@ -0,0 +1,66 @@ +import React from 'react'; + +type StartupErrorBoundaryProps = { + children: React.ReactNode; +}; + +type StartupErrorBoundaryState = { + error: Error | null; +}; + +function isConfigurationError(error: Error): boolean { + return /missing supabase environment variables/i.test(error.message); +} + +/** + * Protects the React bootstrap from module-load and render failures. + * + * App is intentionally lazy loaded from `main.tsx`. This boundary therefore + * remains available when App's Supabase dependency rejects during import, + * which would otherwise leave the player on an empty frame. + */ +export class StartupErrorBoundary extends React.Component< + StartupErrorBoundaryProps, + StartupErrorBoundaryState +> { + state: StartupErrorBoundaryState = { error: null }; + + static getDerivedStateFromError(error: Error): StartupErrorBoundaryState { + return { error }; + } + + private retry = () => { + window.location.reload(); + }; + + render() { + const { error } = this.state; + + if (!error) { + return this.props.children; + } + + const configurationError = isConfigurationError(error); + + return ( +
+
+ DEALT / SLIDE +

+ {configurationError ? 'SYSTEM CONFIGURATION REQUIRED' : 'SYSTEM UNAVAILABLE'} +

+

+ {configurationError + ? 'This game environment is missing a required service configuration. Please contact the deployment owner or try again after the environment is configured.' + : 'SLIDE could not finish starting. Please try again. If the problem continues, contact the deployment owner.'} +

+ +
+
+ ); + } +} + +export default StartupErrorBoundary; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 1dc4351..472bf34 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,5 +1,7 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; +import StartupErrorBoundary from './components/system/StartupErrorBoundary'; + const App = React.lazy(() => import('./App')); const ShooterGraphicsLab = React.lazy(() => import('./components/dev/ShooterGraphicsLab')); @@ -12,14 +14,16 @@ import './styles/mobile.css'; ReactDOM.createRoot(document.getElementById('root')!).render( - {graphicsLabEnabled ? ( - }> - - - ) : ( - }> - - - )} + + {graphicsLabEnabled ? ( + }> + + + ) : ( + }> + + + )} + );