Add an error boundary and split the bundle (0.50.0)
F15 — Two unrelated frontend weaknesses. A render error unmounted the whole React tree: a white window, no navigation, no indication of what happened, and the only way out was knowing to reload. ErrorBoundary now shows the message with a retry and a reload, and clears itself when resetKey (the route) changes, so navigating to a working page just works instead of staying stuck. There are two: one inside AppShell around the routed pages, one at the root for the shell itself and the login screen, which sit outside it. The bundle was one 841 kB file (234 kB gzipped) every visitor downloaded in full, with Vite warning about it on every build. Routes are lazy now and the entry chunk is 377 kB (120 kB gzipped) — a 55% cut, warning gone. Measuring first changed what to split. Monaco turned out not to be in the bundle at all: @monaco-editor/react loads it from cdn.jsdelivr.net, so only the small wrapper ships. xterm.js *is* bundled, all 294 kB of it, and it was reachable from ContainerCard — which renders on every stack detail page — so every visitor paid for a terminal most never open. It is lazy now and lands in its own chunk. (Worth knowing separately: the compose editor therefore needs jsdelivr.net reachable. For a self-hosted tool on an air-gapped network that is a real limitation, but vendoring Monaco means +3 MB and is its own change.) Adding a boundary whose behaviour I could only reason about was not good enough, and the missing frontend test runner was already flagged as the gap from 0.49.0. So this also sets up vitest + jsdom + testing-library and covers the boundary: that it renders the error rather than a blank page, offers a way out, clears on navigation, and stays put on an unrelated re-render. CI runs `npm test` next to pytest. One snag worth recording: installing the dev dependencies triggered npm's optional-dependency pruning and dropped @rollup/rollup-linux-x64-gnu, which broke the build. Reinstalling it directly put a linux-x64-glibc binary in package.json, which would have broken `npm ci` on every other platform — so that was backed out and the lockfile now carries the bindings as rollup's optional deps, where they belong. Verified with a clean `npm ci` in a scratch copy: install, typecheck, build and test all pass from the committed lockfile. 758 backend tests, 7 frontend tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dk43rmEeRfYi5wsLDbmfyG
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
# Continuous integration on git.menzel.center (Gitea Actions).
|
||||
#
|
||||
# Two jobs: `check` runs the test suite, the linter and the frontend
|
||||
# typecheck; `build-and-push` only starts once `check` is green, so a red
|
||||
# suite never reaches the registry (and never reaches the self-update
|
||||
# Two jobs: `check` runs both test suites (pytest, vitest), the linter and the
|
||||
# frontend typecheck; `build-and-push` only starts once `check` is green, so a
|
||||
# red suite never reaches the registry (and never reaches the self-update
|
||||
# checker, which would happily offer a broken release).
|
||||
#
|
||||
# Builds and pushes both images to this instance's container registry on every
|
||||
@@ -68,6 +68,10 @@ jobs:
|
||||
working-directory: frontend
|
||||
run: npx tsc --noEmit -p tsconfig.json
|
||||
|
||||
- name: Test (vitest)
|
||||
working-directory: frontend
|
||||
run: npm test
|
||||
|
||||
build-and-push:
|
||||
needs: check
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -13,6 +13,19 @@ as intuitive as Dockge, as capable as Portainer for Compose workflows.
|
||||
> (Auto-update) + Phase 23 (Secrets & configs) + Phase 24 (Design System v2)
|
||||
> complete.
|
||||
|
||||
## Upgrading to 0.50.0 — nothing to do
|
||||
|
||||
Two robustness fixes, no configuration changes.
|
||||
|
||||
- **A render error no longer blanks the window.** Any exception thrown while
|
||||
rendering used to unmount the whole React tree: a white page with no
|
||||
navigation and no clue what happened. An error boundary now shows what broke
|
||||
with a way out, and clears itself when you navigate to another page.
|
||||
- **The bundle is split.** It was one 841 kB file every visitor downloaded in
|
||||
full; the entry chunk is now 377 kB (120 kB gzipped) with each page fetched on
|
||||
first open. xterm.js, at 294 kB the single largest piece, loads only when
|
||||
somebody actually opens a container terminal.
|
||||
|
||||
## Upgrading to 0.49.0 — nothing to do
|
||||
|
||||
The UI now refreshes when Docker changes instead of asking every few seconds.
|
||||
@@ -152,6 +165,10 @@ it is what your saved destination credentials are encrypted with.
|
||||
holds across workers and across a restart) and a second one gets `409` while
|
||||
it is held; auto-update skips a stack somebody is already deploying. Locks
|
||||
carry an expiry, so a worker killed mid-deploy does not strand a stack.
|
||||
- **Resilient UI** — a render error shows what broke and offers a way out
|
||||
instead of blanking the window, and clears itself when you navigate away.
|
||||
Routes are code-split, so the entry bundle is 377 kB rather than 841 kB and
|
||||
the container terminal's xterm.js only loads when a terminal is opened.
|
||||
- **Event-driven UI** — a single `/ws/events` connection carries Docker's own
|
||||
container / image / network / volume events; the client drops the matching
|
||||
caches so pages refresh the moment something changes, instead of every page
|
||||
@@ -534,10 +551,15 @@ cd backend
|
||||
pip install -r requirements-dev.txt
|
||||
pytest # 758 tests, no Docker daemon needed
|
||||
ruff check .
|
||||
cd ../frontend && npx tsc --noEmit -p tsconfig.json
|
||||
cd ../frontend && npx tsc --noEmit -p tsconfig.json && npm test
|
||||
```
|
||||
|
||||
The suite drives the app through `TestClient` **without** the lifespan, so it
|
||||
The frontend has a small vitest suite alongside it (`npm test`, jsdom). It
|
||||
covers behaviour the typechecker cannot see — currently the error boundary:
|
||||
that it renders the error instead of a blank page, offers a way out, and clears
|
||||
on navigation so one broken page does not strand you.
|
||||
|
||||
The backend suite drives the app through `TestClient` **without** the lifespan, so it
|
||||
never opens a Docker socket and never starts the background loops; `conftest.py`
|
||||
points `DATA_DIR`/`STACKS_DIR` at a temp directory before anything is imported.
|
||||
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
"""Single source of truth for the StackPilot release version."""
|
||||
|
||||
APP_VERSION = "0.49.0"
|
||||
APP_VERSION = "0.50.0"
|
||||
|
||||
Generated
+1532
-3
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,13 @@
|
||||
{
|
||||
"name": "stackpilot-frontend",
|
||||
"private": true,
|
||||
"version": "0.49.0",
|
||||
"version": "0.50.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/schibsted-grotesk": "^5.2.8",
|
||||
@@ -25,14 +26,18 @@
|
||||
"zustand": "^5.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@types/node": "^20.17.10",
|
||||
"@types/react": "^18.3.17",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"jsdom": "^25.0.1",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^5.4.11"
|
||||
"vite": "^5.4.11",
|
||||
"vitest": "^2.1.8"
|
||||
}
|
||||
}
|
||||
|
||||
+30
-11
@@ -1,21 +1,40 @@
|
||||
import { useEffect } from "react";
|
||||
import { lazy, useEffect } from "react";
|
||||
import { BrowserRouter, Navigate, Outlet, Route, Routes } from "react-router-dom";
|
||||
import { AppShell } from "@/components/layout/AppShell";
|
||||
import { Login } from "@/pages/Login";
|
||||
import { Dashboard } from "@/pages/Dashboard";
|
||||
import { Stacks } from "@/pages/Stacks";
|
||||
import { StackDetail } from "@/pages/StackDetail";
|
||||
import { StackEditor } from "@/pages/StackEditor";
|
||||
import { Images } from "@/pages/Images";
|
||||
import { Files } from "@/pages/Files";
|
||||
import { Volumes } from "@/pages/Volumes";
|
||||
import { Templates } from "@/pages/Templates";
|
||||
import { Settings } from "@/pages/Settings";
|
||||
import { Audit } from "@/pages/Audit";
|
||||
import { Networks } from "@/pages/Networks";
|
||||
import { useAuthStore } from "@/store/auth";
|
||||
import { useThemeStore } from "@/store/theme";
|
||||
|
||||
/**
|
||||
* Routes are split so a page's code is fetched when it is first opened.
|
||||
*
|
||||
* Everything used to land in one chunk that every visitor downloaded in full,
|
||||
* including the two heavy pieces most sessions never touch: the compose editor
|
||||
* (Monaco's React wrapper plus the diff view) and, through the container card,
|
||||
* xterm.js.
|
||||
*
|
||||
* Login and the Dashboard stay eagerly imported — they are the first thing
|
||||
* everyone sees, and deferring them would only add a spinner to the critical
|
||||
* path.
|
||||
*/
|
||||
const Stacks = lazy(() => import("@/pages/Stacks").then((m) => ({ default: m.Stacks })));
|
||||
const StackDetail = lazy(() =>
|
||||
import("@/pages/StackDetail").then((m) => ({ default: m.StackDetail }))
|
||||
);
|
||||
const StackEditor = lazy(() =>
|
||||
import("@/pages/StackEditor").then((m) => ({ default: m.StackEditor }))
|
||||
);
|
||||
const Images = lazy(() => import("@/pages/Images").then((m) => ({ default: m.Images })));
|
||||
const Files = lazy(() => import("@/pages/Files").then((m) => ({ default: m.Files })));
|
||||
const Volumes = lazy(() => import("@/pages/Volumes").then((m) => ({ default: m.Volumes })));
|
||||
const Templates = lazy(() =>
|
||||
import("@/pages/Templates").then((m) => ({ default: m.Templates }))
|
||||
);
|
||||
const Settings = lazy(() => import("@/pages/Settings").then((m) => ({ default: m.Settings })));
|
||||
const Audit = lazy(() => import("@/pages/Audit").then((m) => ({ default: m.Audit })));
|
||||
const Networks = lazy(() => import("@/pages/Networks").then((m) => ({ default: m.Networks })));
|
||||
|
||||
function RequireAuth() {
|
||||
const token = useAuthStore((s) => s.accessToken);
|
||||
const ready = useAuthStore((s) => s.ready);
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { Suspense } from "react";
|
||||
import { Outlet, useLocation } from "react-router-dom";
|
||||
import { TopNav } from "./TopNav";
|
||||
import { ErrorBoundary } from "@/components/ui/ErrorBoundary";
|
||||
import { Spinner } from "@/components/ui";
|
||||
import { useDockerEvents } from "@/hooks/useDockerEvents";
|
||||
|
||||
/** Top-level routes get a display-weight title here; the Dashboard ("/")
|
||||
@@ -31,7 +34,14 @@ export function AppShell() {
|
||||
{title && (
|
||||
<h1 className="sp-display mb-6 text-[40px] leading-none sm:text-[50px]">{title}</h1>
|
||||
)}
|
||||
{/* Keyed on the route so navigating away from a broken page clears the
|
||||
error instead of stranding the user on it. Suspense covers the
|
||||
lazily-loaded route chunks. */}
|
||||
<ErrorBoundary resetKey={pathname}>
|
||||
<Suspense fallback={<Spinner />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
import { useState } from "react";
|
||||
import { Suspense, lazy, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Play, Square, RotateCw, ChevronDown, ChevronRight, TerminalSquare } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Badge, Button, Card, Spinner, StatusDot } from "@/components/ui";
|
||||
import { ContainerPorts } from "@/components/stacks/ContainerPorts";
|
||||
import { ContainerTerminal } from "@/components/stacks/ContainerTerminal";
|
||||
import { containersApi, type ContainerAction } from "@/api/containers";
|
||||
import { apiErrorMessage } from "@/api/client";
|
||||
import type { ContainerInfo } from "@/types";
|
||||
|
||||
/**
|
||||
* xterm.js is ~250 kB and only needed once somebody actually opens a terminal.
|
||||
* Loading it here kept it in the main bundle for every visitor, because this
|
||||
* card renders on every stack detail page.
|
||||
*/
|
||||
const ContainerTerminal = lazy(() =>
|
||||
import("@/components/stacks/ContainerTerminal").then((m) => ({
|
||||
default: m.ContainerTerminal,
|
||||
}))
|
||||
);
|
||||
|
||||
export function ContainerCard({
|
||||
container,
|
||||
host,
|
||||
@@ -148,11 +158,13 @@ export function ContainerCard({
|
||||
)}
|
||||
|
||||
{termOpen && (
|
||||
<Suspense fallback={null}>
|
||||
<ContainerTerminal
|
||||
containerId={container.id}
|
||||
service={container.service}
|
||||
onClose={() => setTermOpen(false)}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { useState } from "react";
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, cleanup, fireEvent } from "@testing-library/react";
|
||||
import { ErrorBoundary } from "./ErrorBoundary";
|
||||
|
||||
/**
|
||||
* Before this component existed, any render error unmounted the whole React
|
||||
* tree: a white window, no navigation, no clue what happened. That failure mode
|
||||
* is invisible to the typechecker, which is what these cover.
|
||||
*/
|
||||
|
||||
function Boom({ shouldThrow }: { shouldThrow: boolean }): React.ReactElement {
|
||||
if (shouldThrow) throw new Error("the sky is falling");
|
||||
return <p>all good</p>;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// React logs caught errors itself; silence it so a passing run stays readable.
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("ErrorBoundary", () => {
|
||||
it("renders its children when nothing goes wrong", () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<Boom shouldThrow={false} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
expect(screen.getByText("all good")).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows the error instead of a blank page", () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<Boom shouldThrow />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
expect(screen.getByText("This page hit an error")).toBeDefined();
|
||||
// The message is surfaced, not swallowed — otherwise there is nothing to
|
||||
// report and no way to tell what broke.
|
||||
expect(screen.getByText("the sky is falling")).toBeDefined();
|
||||
});
|
||||
|
||||
it("offers a way out", () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<Boom shouldThrow />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
expect(screen.getByRole("button", { name: /try again/i })).toBeDefined();
|
||||
expect(screen.getByRole("button", { name: /reload/i })).toBeDefined();
|
||||
});
|
||||
|
||||
it("says the rest of the app is unaffected", () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<Boom shouldThrow />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
expect(screen.getByText(/your stacks are unaffected/i)).toBeDefined();
|
||||
});
|
||||
|
||||
it("clears the error when resetKey changes", () => {
|
||||
// This is what makes navigating away from a broken page work: without it
|
||||
// the boundary keeps showing the error on every subsequent route.
|
||||
function Harness() {
|
||||
const [route, setRoute] = useState("/broken");
|
||||
return (
|
||||
<>
|
||||
<button onClick={() => setRoute("/fine")}>navigate</button>
|
||||
<ErrorBoundary resetKey={route}>
|
||||
<Boom shouldThrow={route === "/broken"} />
|
||||
</ErrorBoundary>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
render(<Harness />);
|
||||
expect(screen.getByText("This page hit an error")).toBeDefined();
|
||||
|
||||
fireEvent.click(screen.getByText("navigate"));
|
||||
|
||||
expect(screen.queryByText("This page hit an error")).toBeNull();
|
||||
expect(screen.getByText("all good")).toBeDefined();
|
||||
});
|
||||
|
||||
it("stays on the error screen while the route is unchanged", () => {
|
||||
function Harness() {
|
||||
const [, force] = useState(0);
|
||||
return (
|
||||
<>
|
||||
<button onClick={() => force((n) => n + 1)}>rerender</button>
|
||||
<ErrorBoundary resetKey="/same">
|
||||
<Boom shouldThrow />
|
||||
</ErrorBoundary>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
render(<Harness />);
|
||||
fireEvent.click(screen.getByText("rerender"));
|
||||
expect(screen.getByText("This page hit an error")).toBeDefined();
|
||||
});
|
||||
|
||||
it("logs the error so it is recoverable from the console", () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<Boom shouldThrow />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
const logged = vi
|
||||
.mocked(console.error)
|
||||
.mock.calls.some((args) => String(args[0]).includes("Render error:"));
|
||||
expect(logged).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Component, type ErrorInfo, type ReactNode } from "react";
|
||||
import { AlertTriangle, RefreshCw } from "lucide-react";
|
||||
import { Button } from "@/components/ui";
|
||||
|
||||
/**
|
||||
* Catches render errors so one broken page does not blank the whole app.
|
||||
*
|
||||
* Without this, any exception thrown while rendering unmounts the entire React
|
||||
* tree: the user gets a white window with no navigation and no indication of
|
||||
* what happened, and the only way out is to know to reload. React offers no
|
||||
* hook equivalent — catching render errors requires a class component.
|
||||
*
|
||||
* `resetKey` is how the boundary recovers on navigation: change it (the route
|
||||
* path) and the boundary clears, so moving to a working page just works instead
|
||||
* of staying stuck on the error screen.
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
/** Changing this clears a caught error — pass the current route. */
|
||||
resetKey?: string;
|
||||
}
|
||||
|
||||
interface State {
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
state: State = { error: null };
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { error };
|
||||
}
|
||||
|
||||
componentDidUpdate(prev: Props) {
|
||||
if (this.state.error && prev.resetKey !== this.props.resetKey) {
|
||||
this.setState({ error: null });
|
||||
}
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
// The browser console is the only place this can go — there is no error
|
||||
// reporting backend, and inventing one is not this component's job.
|
||||
console.error("Render error:", error, info.componentStack);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { error } = this.state;
|
||||
if (!error) return this.props.children;
|
||||
|
||||
return (
|
||||
<div className="sp-card mx-auto max-w-2xl p-6">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<AlertTriangle className="h-5 w-5 text-red-500" />
|
||||
<h2 className="sp-heading text-lg">This page hit an error</h2>
|
||||
</div>
|
||||
<p className="mb-4 text-sm text-sp-text-2">
|
||||
The rest of StackPilot is still running — your stacks are unaffected.
|
||||
Try again, or pick another page from the menu.
|
||||
</p>
|
||||
<pre className="mb-4 max-h-48 overflow-auto rounded-lg bg-sp-surface-2 p-3 font-mono text-xs text-sp-text-2">
|
||||
{error.message || String(error)}
|
||||
</pre>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={() => this.setState({ error: null })}>
|
||||
<RefreshCw className="h-4 w-4" /> Try again
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => window.location.reload()}>
|
||||
Reload the page
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import ReactDOM from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { Toaster } from "sonner";
|
||||
import App from "./App";
|
||||
import { ErrorBoundary } from "@/components/ui/ErrorBoundary";
|
||||
import "./index.css";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
@@ -12,7 +13,12 @@ const queryClient = new QueryClient({
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{/* Last line of defence. The boundary inside AppShell covers the routed
|
||||
pages; this one catches the shell itself and the login screen, which
|
||||
sit outside it. */}
|
||||
<ErrorBoundary>
|
||||
<App />
|
||||
</ErrorBoundary>
|
||||
<Toaster position="top-right" richColors theme="system" expand visibleToasts={5} />
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/// <reference types="vitest" />
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import path from "path";
|
||||
@@ -13,6 +14,13 @@ export default defineConfig({
|
||||
resolve: {
|
||||
alias: { "@": path.resolve(__dirname, "./src") },
|
||||
},
|
||||
// Component tests run in jsdom. Kept minimal on purpose: this exists to
|
||||
// verify behaviour that typechecking cannot, not to chase coverage.
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
globals: true,
|
||||
include: ["src/**/*.test.{ts,tsx}"],
|
||||
},
|
||||
server: {
|
||||
host: true,
|
||||
port: 5173,
|
||||
|
||||
Reference in New Issue
Block a user