From 1873787ee6b21596081e2d586b0f02db86e7eaf5 Mon Sep 17 00:00:00 2001
From: stroblme
Date: Sat, 29 Aug 2026 21:00:02 +0200
Subject: [PATCH] Compress what the browser downloads, and stop one tile taking
the page
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
**nginx served the bundle uncompressed and uncacheable.** The base image
ships gzip commented out and nothing set `Cache-Control`, so every load
carried the whole thing and every reload cost a 304 per asset. Measured on
the built image: the entry chunk 838 kB → 311 kB, the Monaco chunk 2.66 MB
→ 832 kB, and the ~50 content-hashed assets are now immutable for a year.
`index.html` is explicitly `no-cache`, since it is what names the rest.
**A widget that throws no longer blanks the screen.** There was one error
boundary in the app, on the root route, so anything that threw replaced
everything including the navigation — on `/view/{name}`, an unattended wall
panel with no way back. Each tile has its own boundary now, and the app
shell has one inside it so a screen that fails leaves the sidebar standing.
`react-error-boundary` was already a dependency and imported nowhere.
**`localStorage` cannot take the app down.** Reaching it raises where the
browser blocks site data, and `setItem` raises once the origin's quota is
full — which the flow editor's node clipboard, carrying whole Python
sources, can genuinely reach. Thrown from a key handler that escaped to
`window.onerror`, which the single root boundary then turned into a blank
page. `lib/safeStorage.ts` is the guarded pair the pre-paint theme script in
`index.html` was already using; a copy too large to store now says so.
Queries default to `staleTime: 5000` — below every poll interval on any
screen, so nothing polls less often than it did, but a route mounting twice
in a few seconds stops refetching everything it touches. Window-focus
refetching is off: the socket pushes what changes and a reconnect
invalidates what it feeds, so a focus event has nothing of its own to say.
Home alone reads about ten queries on every one of those.
Render cost, two that showed up in the audit:
- `LogsPanel` was rendered unconditionally by the dock and decided inside
itself whether to draw, so with the panel *shut* it still subscribed to
the log store and re-filtered five hundred lines per line a flow
published. It returns before any of that now.
- `HealthActivity` subscribed to the whole engine-event array and used one
number from it, so a flapping node re-rendered the component that draws
Home's two uPlot charts — each of which rebuilds its series on every
render by design. It subscribes to that number.
- the global search bucketed the index nine times per keystroke, once per
group. One pass, and each group offers at most twenty.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01M6hPWS6YEbT1P8LxhhFb2T
---
frontend/nginx.conf | 31 ++++++++++++++
.../src/components/Common/GlobalSearch.tsx | 24 ++++++++---
frontend/src/components/Dashboard/widgets.tsx | 21 +++++++++-
frontend/src/components/Flow/FlowEditor.tsx | 12 +++++-
frontend/src/components/Flow/LogsPanel.tsx | 11 ++++-
frontend/src/components/Flow/liveStore.ts | 19 +++++++++
.../src/components/Health/HealthActivity.tsx | 9 +++--
frontend/src/components/theme-provider.tsx | 6 ++-
frontend/src/hooks/useAuth.ts | 7 ++--
frontend/src/lib/safeStorage.ts | 40 +++++++++++++++++++
frontend/src/main.tsx | 13 +++++-
frontend/src/routes/_layout.tsx | 5 +++
12 files changed, 178 insertions(+), 20 deletions(-)
create mode 100644 frontend/src/lib/safeStorage.ts
diff --git a/frontend/nginx.conf b/frontend/nginx.conf
index ba4d9aa..a8a42be 100644
--- a/frontend/nginx.conf
+++ b/frontend/nginx.conf
@@ -1,11 +1,42 @@
server {
listen 80;
+ # The bundle is ~770 kB of JavaScript and ~85 kB of CSS, and nginx's base
+ # image ships gzip commented out — so every first load shipped all of it
+ # uncompressed. Roughly a third of the bytes with this on.
+ gzip on;
+ gzip_vary on;
+ gzip_min_length 1024;
+ gzip_proxied any;
+ gzip_types
+ application/javascript
+ application/json
+ application/manifest+json
+ application/wasm
+ image/svg+xml
+ text/css
+ text/plain;
+
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri /index.html =404;
}
+ # Vite puts a content hash in every asset's name, so one can never change
+ # under its URL. Without this each of the ~50 of them cost a 304 round trip
+ # on every reload.
+ location /assets/ {
+ root /usr/share/nginx/html;
+ add_header Cache-Control "public, max-age=31536000, immutable";
+ }
+
+ # The one file that must not be cached: it is what names the current
+ # assets, so a stale copy pins the browser to the previous build.
+ location = /index.html {
+ root /usr/share/nginx/html;
+ add_header Cache-Control "no-cache";
+ }
+
include /etc/nginx/extra-conf.d/*.conf;
}
diff --git a/frontend/src/components/Common/GlobalSearch.tsx b/frontend/src/components/Common/GlobalSearch.tsx
index 4dbdba1..efc524e 100644
--- a/frontend/src/components/Common/GlobalSearch.tsx
+++ b/frontend/src/components/Common/GlobalSearch.tsx
@@ -31,6 +31,11 @@ export const searchQueryOptions = () => ({
})
/** The categories, in the order they are offered, with what to draw each as. */
+//: The most entries one group shows. cmdk scores what is mounted, so this is
+//: a cap on what is offered rather than on what is searched — and twenty of a
+//: kind is already more than anybody reads before typing another letter.
+const GROUP_CAP = 20
+
const GROUPS: {
category: SearchEntry["category"]
label: string
@@ -58,8 +63,10 @@ function hint(entry: SearchEntry): string {
* The whole index arrives in one fetch and `cmdk` does the matching, so results
* narrow as they are typed without a round trip per keystroke.
*
- * ponytail: every entry is rendered and cmdk hides the ones that do not match.
- * Cap the groups if an installation ever grows big enough to feel it.
+ * cmdk does the matching over what is mounted, so the entries are bucketed by
+ * category once per fetch rather than filtered nine times per keystroke, and
+ * each group is capped — a list nobody can scroll to the end of is not a
+ * better answer than its first twenty.
*/
export function GlobalSearch({
open,
@@ -79,6 +86,15 @@ export function GlobalSearch({
const entries = data ?? []
const typing = query.trim().length > 0
+ // One pass over the index rather than one per group — nine passes over
+ // every entry on each keystroke. Not memoised: the component returns early
+ // while closed, so a hook cannot go here, and one pass is already the win.
+ const byCategory = new Map()
+ for (const entry of entries) {
+ const bucket = byCategory.get(entry.category)
+ if (bucket) bucket.push(entry)
+ else byCategory.set(entry.category, [entry])
+ }
const go = (entry: SearchEntry) => {
onOpenChange(false)
@@ -144,9 +160,7 @@ export function GlobalSearch({
<>
Nothing matches that.
{GROUPS.map(({ category, label, icon: Icon }) => {
- const found = entries.filter(
- (entry) => entry.category === category,
- )
+ const found = (byCategory.get(category) ?? []).slice(0, GROUP_CAP)
if (found.length === 0) return null
return (
diff --git a/frontend/src/components/Dashboard/widgets.tsx b/frontend/src/components/Dashboard/widgets.tsx
index 63e2c36..a6fa019 100644
--- a/frontend/src/components/Dashboard/widgets.tsx
+++ b/frontend/src/components/Dashboard/widgets.tsx
@@ -1,4 +1,6 @@
+import { TriangleAlert } from "lucide-react"
import { useState } from "react"
+import { ErrorBoundary } from "react-error-boundary"
import type { WidgetDef } from "@/client"
import { cn } from "@/lib/utils"
@@ -637,5 +639,22 @@ export function WidgetBody({ widget, dashboard }: WidgetProps) {
)
}
- return
+ return (
+ // Per tile, because a dashboard is often the only thing on a screen
+ // nobody is standing at: one widget whose config the renderer cannot make
+ // sense of used to throw past every ancestor and leave a wall panel
+ // blank, with no navigation to recover from.
+ } resetKeys={[widget.id]}>
+
+
+ )
+}
+
+function WidgetFailed() {
+ return (
+
+
+ This tile could not be drawn.
+
+ )
}
diff --git a/frontend/src/components/Flow/FlowEditor.tsx b/frontend/src/components/Flow/FlowEditor.tsx
index 69e0d76..3f2da2b 100644
--- a/frontend/src/components/Flow/FlowEditor.tsx
+++ b/frontend/src/components/Flow/FlowEditor.tsx
@@ -45,6 +45,8 @@ import {
import useCustomToast from "@/hooks/useCustomToast"
import { useIsMobile } from "@/hooks/useMobile"
import { scaleIn } from "@/lib/motion"
+import { notify } from "@/lib/notificationStore"
+import { safeStorage } from "@/lib/safeStorage"
import { inCodeEditor, useShortcuts } from "@/lib/shortcuts"
import { cn } from "@/lib/utils"
import { CanvasTitle } from "./CanvasTitle"
@@ -999,10 +1001,16 @@ function FlowEditorInner({
}),
).catch(() => undefined)
- localStorage.setItem(
+ // A multi-node copy carries whole Python sources, which can genuinely
+ // reach the origin's quota — and a throw from a key handler would take
+ // the app down rather than the copy.
+ const stored = safeStorage.set(
CLIPBOARD_KEY,
JSON.stringify({ nodes: picked, sources } satisfies NodeClipboard),
)
+ if (!stored) {
+ notify("That is too much to copy — the browser store is full.", "error")
+ }
}, [canvasNodes, definitions, flowName, selectedId, sourceTypes])
/**
@@ -1011,7 +1019,7 @@ function FlowEditorInner({
* the definition is on its way.
*/
const pasteNodes = useCallback(() => {
- const stored = localStorage.getItem(CLIPBOARD_KEY)
+ const stored = safeStorage.get(CLIPBOARD_KEY)
if (!stored) return
let clipboard: NodeClipboard
try {
diff --git a/frontend/src/components/Flow/LogsPanel.tsx b/frontend/src/components/Flow/LogsPanel.tsx
index 849e80d..2348782 100644
--- a/frontend/src/components/Flow/LogsPanel.tsx
+++ b/frontend/src/components/Flow/LogsPanel.tsx
@@ -82,7 +82,16 @@ export function LogsTrigger({
* Opening it is not only the dock's to do: a failing node points straight at
* its own traceback, which is why the open state lives in the editor.
*/
-export function LogsPanel({
+export function LogsPanel(props: { flow: string } & LogsFilter) {
+ // The dock renders this unconditionally and the `AnimatePresence` inside
+ // decides whether to draw — so with the panel shut it still subscribed to
+ // the log store and re-filtered five hundred lines on every line a flow
+ // published. Nothing below runs until it is open.
+ if (!props.open) return null
+ return
+}
+
+function OpenLogsPanel({
flow,
open,
node,
diff --git a/frontend/src/components/Flow/liveStore.ts b/frontend/src/components/Flow/liveStore.ts
index ce72b10..e18be4c 100644
--- a/frontend/src/components/Flow/liveStore.ts
+++ b/frontend/src/components/Flow/liveStore.ts
@@ -297,6 +297,25 @@ export function useEngineEvents(): EngineEvent[] {
)
}
+/**
+ * The minute the newest engine event landed in, or 0 for none yet.
+ *
+ * A scalar, so a subscriber that only wants "has something gone wrong
+ * lately" re-renders when the answer changes rather than on every event.
+ * `recordEngineEvent` allocates a new array each time, so subscribing to the
+ * array itself means a flapping node re-renders whatever is watching — which
+ * on Home is two uPlot charts that rebuild their series from scratch.
+ */
+export function useEngineEventMinute(): number {
+ return useSyncExternalStore(
+ (listener) => subscribeKey("engine", listener),
+ () =>
+ engineEvents.length
+ ? Math.floor(engineEvents[engineEvents.length - 1].ts / 60)
+ : 0,
+ )
+}
+
/** Increments each time the node publishes something. */
export function useNodeEmits(nodeId: string): number {
return useSyncExternalStore(
diff --git a/frontend/src/components/Health/HealthActivity.tsx b/frontend/src/components/Health/HealthActivity.tsx
index 4e4dbfd..f3bfca4 100644
--- a/frontend/src/components/Health/HealthActivity.tsx
+++ b/frontend/src/components/Health/HealthActivity.tsx
@@ -5,7 +5,7 @@ import { useEffect, useState } from "react"
import type { EventRow, HistoryPoint } from "@/client"
import type { Range } from "@/components/Common/RangePicker"
import { UplotChart } from "@/components/Common/UplotChart"
-import { useEngineEvents } from "@/components/Flow/liveStore"
+import { useEngineEventMinute } from "@/components/Flow/liveStore"
import { PANEL_SECTION } from "@/components/Flow/SidePanel"
import { Button } from "@/components/ui/button"
import { cn, dur, si } from "@/lib/utils"
@@ -218,7 +218,7 @@ function Failure({ event }: { event: EventRow }) {
* the charts are drawn from rather than however far their newest rows reach.
*/
export function HealthActivity({ range }: { range: Range }) {
- const live = useEngineEvents()
+ const seen = useEngineEventMinute()
const queryClient = useQueryClient()
const runsAt = useMoment()
const failuresAt = useMoment()
@@ -241,8 +241,9 @@ export function HealthActivity({ range }: { range: Range }) {
//
// The newest event's minute, not the count: the count stops changing once the
// ring is full, and a per-event key would let a flapping node restart the
- // timer forever without it ever firing.
- const seen = live.length ? Math.floor(live[live.length - 1].ts / 60) : 0
+ // timer forever without it ever firing. Subscribed to as that scalar rather
+ // than as the events array, so an event inside a minute already seen does
+ // not re-render the two charts below.
useEffect(() => {
if (!seen) return
const timer = setTimeout(() => {
diff --git a/frontend/src/components/theme-provider.tsx b/frontend/src/components/theme-provider.tsx
index 9d9d9d4..2d91fb4 100644
--- a/frontend/src/components/theme-provider.tsx
+++ b/frontend/src/components/theme-provider.tsx
@@ -6,6 +6,8 @@ import {
useState,
} from "react"
+import { safeStorage } from "@/lib/safeStorage"
+
export type Theme = "dark" | "light" | "system"
type ThemeProviderProps = {
@@ -37,7 +39,7 @@ export function ThemeProvider({
const [theme, setTheme] = useState(
() =>
(typeof localStorage !== "undefined"
- ? (localStorage.getItem(storageKey) as Theme)
+ ? (safeStorage.get(storageKey) as Theme)
: null) || defaultTheme,
)
@@ -99,7 +101,7 @@ export function ThemeProvider({
theme,
resolvedTheme,
setTheme: (theme: Theme) => {
- localStorage.setItem(storageKey, theme)
+ safeStorage.set(storageKey, theme)
setTheme(theme)
},
}
diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts
index 67ba127..6828fa6 100644
--- a/frontend/src/hooks/useAuth.ts
+++ b/frontend/src/hooks/useAuth.ts
@@ -10,6 +10,7 @@ import {
} from "@/client"
import { liveStore } from "@/components/Flow/liveStore"
import { isPortal } from "@/lib/portal"
+import { safeStorage } from "@/lib/safeStorage"
import { handleError } from "@/utils"
import useCustomToast from "./useCustomToast"
@@ -17,7 +18,7 @@ const isLoggedIn = () => {
// Under a portal the credential comes with the page rather than from
// storage, and the sign-in already happened there.
if (isPortal()) return true
- return localStorage.getItem("access_token") !== null
+ return safeStorage.get("access_token") !== null
}
/**
@@ -58,7 +59,7 @@ const useAuth = () => {
const response = await LoginService.loginAccessToken({
formData: data,
})
- localStorage.setItem("access_token", response.access_token)
+ safeStorage.set("access_token", response.access_token)
}
const loginMutation = useMutation({
@@ -72,7 +73,7 @@ const useAuth = () => {
})
const logout = () => {
- localStorage.removeItem("access_token")
+ safeStorage.remove("access_token")
// Live values belong to the session that was watching them.
liveStore.reset()
navigate({ to: "/login" })
diff --git a/frontend/src/lib/safeStorage.ts b/frontend/src/lib/safeStorage.ts
new file mode 100644
index 0000000..961be95
--- /dev/null
+++ b/frontend/src/lib/safeStorage.ts
@@ -0,0 +1,40 @@
+/**
+ * `localStorage` that cannot throw.
+ *
+ * Reaching `window.localStorage` at all raises `SecurityError` where the
+ * browser is set to block site data, and `setItem` raises `QuotaExceededError`
+ * once the origin's five megabytes are full — which the flow editor's node
+ * clipboard, carrying whole Python sources, can genuinely reach. Either one
+ * thrown from an event handler escapes to `window.onerror`, and with a single
+ * error boundary at the root that blanks the app.
+ *
+ * The pre-paint theme script in `index.html` already guards its own read this
+ * way; this is the same thing for everything that runs after it.
+ */
+export const safeStorage = {
+ get(key: string): string | null {
+ try {
+ return localStorage.getItem(key)
+ } catch {
+ return null
+ }
+ },
+
+ /** Whether it was actually stored, for the rare caller that wants to know. */
+ set(key: string, value: string): boolean {
+ try {
+ localStorage.setItem(key, value)
+ return true
+ } catch {
+ return false
+ }
+ },
+
+ remove(key: string): void {
+ try {
+ localStorage.removeItem(key)
+ } catch {
+ // Nothing to clean up if it was never reachable.
+ }
+ },
+}
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
index b3a52a1..2ccb887 100644
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -16,6 +16,7 @@ import "./index.css"
import { connectionStore, offlineDetail } from "./lib/connectionStore"
import { notify } from "./lib/notificationStore"
import { apiToken, appPath, appRoute, portalConfig } from "./lib/portal"
+import { safeStorage } from "./lib/safeStorage"
import { routeTree } from "./routeTree.gen"
const portal = portalConfig()
@@ -58,7 +59,7 @@ const handleApiError = (error: Error) => {
// A paired wall panel has no login screen to go back to — it asks for a
// new code instead.
if (appRoute().startsWith("/panel")) {
- localStorage.removeItem("access_token")
+ safeStorage.remove("access_token")
window.location.href = appPath("/panel")
return
}
@@ -68,7 +69,7 @@ const handleApiError = (error: Error) => {
window.location.href = `${portal.portalUrl}?reauth=${portal.installationId}`
return
}
- localStorage.removeItem("access_token")
+ safeStorage.remove("access_token")
window.location.href = appPath("/login")
return
}
@@ -94,6 +95,14 @@ const queryClient = new QueryClient({
queries: {
// Retrying an expired session only delays the trip to the login screen.
retry: (count, error) => !isPointlessToRetry(error) && count < 3,
+ // Below every poll interval on any screen, so nothing polls less often
+ // than it did — but a route that mounts twice in a few seconds, or a
+ // tab coming back to the foreground, stops refetching everything it
+ // touches. Home alone reads about ten queries.
+ staleTime: 5_000,
+ // The socket pushes what changes, and a reconnect invalidates what it
+ // feeds, so a focus event has nothing of its own to tell us.
+ refetchOnWindowFocus: false,
},
mutations: {
retry: false,
diff --git a/frontend/src/routes/_layout.tsx b/frontend/src/routes/_layout.tsx
index 63bd00f..771dfd7 100644
--- a/frontend/src/routes/_layout.tsx
+++ b/frontend/src/routes/_layout.tsx
@@ -1,6 +1,7 @@
import { createFileRoute, Outlet, redirect } from "@tanstack/react-router"
import { ConnectionNotice } from "@/components/Common/ConnectionNotice"
+import ErrorComponent from "@/components/Common/ErrorComponent"
import { Footer } from "@/components/Common/Footer"
import { useFlowSocket } from "@/components/Flow/useFlowSocket"
import AppSidebar from "@/components/Sidebar/AppSidebar"
@@ -13,6 +14,10 @@ import { isLoggedIn } from "@/hooks/useAuth"
export const Route = createFileRoute("/_layout")({
component: Layout,
+ // Inside the shell rather than at the root, so a screen that throws leaves
+ // the sidebar standing and somebody can navigate away from it. The root's
+ // own boundary replaces the whole app, navigation included.
+ errorComponent: () => ,
beforeLoad: async () => {
if (!isLoggedIn()) {
throw redirect({