diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md
index fb51993..5522090 100644
--- a/UAT_PLAYBOOK.md
+++ b/UAT_PLAYBOOK.md
@@ -54,6 +54,8 @@ export const { signIn, signOut, useSession, changePassword } = authClient;
| TC-WEB-5.1.3 | Logout | Click logout button | Session cleared, redirected to login page |
| TC-WEB-5.1.4 | Session indicator | After successful login | User info/initials visible in UI indicating active session |
| TC-WEB-5.1.5 | Unauthenticated `/login` renders the form (GRO-2011) | In a private/incognito window with no session cookie, navigate to UAT `/login` | React root mounts; the GroomBook sign-in card with the OIDC button is visible. Network tab shows `/api/auth/get-session` 200, `/api/setup/status` 200, and the login form is rendered (NOT a blank white viewport). |
+| TC-WEB-5.1.6 | Swallowed render error surfaces in DOM (GRO-2094) | Trigger a render-time exception in the React tree (e.g. via temporary throw in a child component on a test build) and load `/login` in a clean context | Either the login form renders normally (happy path) OR the top-level `ErrorBoundary` testid `error-boundary` is visible with a populated `error-boundary-message` pre block showing the exception name/message/stack. **NEVER** a blank `
` with no error indicator. Browser console must contain either zero render errors or a `[ErrorBoundary]` line plus the raw exception. |
+| TC-WEB-5.1.7 | Global `error` and `unhandledrejection` listeners are wired (GRO-2094) | In a clean browser context, load `/login`, then trigger `setTimeout(() => { throw new Error("synthetic") }, 0)` from the console and `Promise.reject(new Error("synthetic-promise"))` | Browser console shows `[window.error]` and `[unhandledrejection]` log lines with the thrown values. Confirms global listeners are active in production. |
### 5.2 Authentication — VITE_API_URL Set
diff --git a/src/ErrorBoundary.tsx b/src/ErrorBoundary.tsx
new file mode 100644
index 0000000..5e0b730
--- /dev/null
+++ b/src/ErrorBoundary.tsx
@@ -0,0 +1,77 @@
+import { Component } from "react";
+import type { ErrorInfo, ReactNode } from "react";
+
+interface ErrorBoundaryProps {
+ children: ReactNode;
+}
+
+interface ErrorBoundaryState {
+ error: Error | null;
+}
+
+/**
+ * Top-level ErrorBoundary — renders the error visibly so the actual exception
+ * appears in the DOM (and therefore in the Playwright snapshot) instead of
+ * React 18+ unmounting the entire tree to a blank `
`.
+ *
+ * Background: GRO-2094. The bundle was executing but never painting, with
+ * the failure swallowed. Surfacing the error here is the first step; the
+ * real fix is in the underlying component that threw.
+ */
+export class ErrorBoundary extends Component
{
+ state: ErrorBoundaryState = { error: null };
+
+ static getDerivedStateFromError(error: Error): ErrorBoundaryState {
+ return { error };
+ }
+
+ componentDidCatch(error: Error, info: ErrorInfo): void {
+ // Also surface to the console — this is what the test harness greps for.
+ // eslint-disable-next-line no-console
+ console.error("[ErrorBoundary] Uncaught render error:", error, info);
+ }
+
+ render() {
+ if (this.state.error) {
+ const err = this.state.error;
+ return (
+
+
Something went wrong
+
+ The app failed to render. The full error is shown below — please share this
+ output when reporting the bug.
+
+
+ {err.name}: {err.message}
+ {"\n\n"}
+ {err.stack ?? "(no stack)"}
+
+
+ );
+ }
+ return this.props.children;
+ }
+}
diff --git a/src/__tests__/ErrorBoundary.test.tsx b/src/__tests__/ErrorBoundary.test.tsx
new file mode 100644
index 0000000..6d6d4c9
--- /dev/null
+++ b/src/__tests__/ErrorBoundary.test.tsx
@@ -0,0 +1,54 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { render, screen, cleanup } from "@testing-library/react";
+import { ErrorBoundary } from "../ErrorBoundary";
+
+function ThrowingChild(): never {
+ throw new Error("synthetic render-time failure for GRO-2094");
+}
+
+function GoodChild() {
+ return ok
;
+}
+
+describe("ErrorBoundary (GRO-2094)", () => {
+ let errorSpy: ReturnType;
+
+ beforeEach(() => {
+ // React 18+ logs caught render errors to console.error via React's own
+ // instrumentation; suppress it so test output is clean but capture it
+ // for an assertion below.
+ errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ errorSpy.mockRestore();
+ cleanup();
+ });
+
+ it("renders children when nothing throws", () => {
+ render(
+
+
+
+ );
+ expect(screen.getByTestId("good-child")).toBeInTheDocument();
+ expect(screen.queryByTestId("error-boundary")).not.toBeInTheDocument();
+ });
+
+ it("renders the error visibly when a child throws during render", () => {
+ render(
+
+
+
+ );
+
+ const fallback = screen.getByTestId("error-boundary");
+ expect(fallback).toBeInTheDocument();
+ const message = screen.getByTestId("error-boundary-message");
+ // The actual exception is shown — no more silent blank root.
+ expect(message.textContent).toContain("synthetic render-time failure for GRO-2094");
+ // The boundary also calls console.error so it shows up in the Playwright
+ // console log even if the DOM-rendered fallback is somehow missed.
+ expect(errorSpy).toHaveBeenCalled();
+ });
+});
diff --git a/src/main.tsx b/src/main.tsx
index 3920a8d..77ffcf1 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -2,9 +2,41 @@ import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { App } from "./App.js";
+import { ErrorBoundary } from "./ErrorBoundary.js";
import { installDevFetchInterceptor } from "./lib/devFetch.js";
import "./index.css";
+// --------------------------------------------------------------------
+// Global error capture (GRO-2094).
+//
+// Symptom: React root stays empty at /login — bundle parses, no console
+// errors, no error boundary fallback. Some failure is being swallowed
+// before it reaches React's commit phase. These listeners make sure any
+// thrown error or unhandled promise rejection is at least visible in
+// the console (and in the Playwright network/console log) instead of
+// vanishing into the void.
+// --------------------------------------------------------------------
+function reportGlobalError(kind: string, payload: unknown): void {
+ // eslint-disable-next-line no-console
+ console.error(`[${kind}]`, payload);
+}
+
+window.addEventListener("error", (event) => {
+ reportGlobalError("window.error", {
+ message: event.message,
+ filename: event.filename,
+ lineno: event.lineno,
+ colno: event.colno,
+ error: event.error,
+ });
+});
+
+window.addEventListener("unhandledrejection", (event) => {
+ reportGlobalError("unhandledrejection", {
+ reason: event.reason,
+ });
+});
+
installDevFetchInterceptor();
const root = document.getElementById("root");
@@ -12,8 +44,10 @@ if (!root) throw new Error("Root element not found");
createRoot(root).render(
-
-
-
+
+
+
+
+
);