Compress what the browser downloads, and stop one tile taking the page

**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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6hPWS6YEbT1P8LxhhFb2T
This commit is contained in:
2026-08-29 21:00:02 +02:00
co-authored by Claude Opus 5
parent 57eace2226
commit 1873787ee6
12 changed files with 178 additions and 20 deletions
+31
View File
@@ -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;
}
@@ -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<string, SearchEntry[]>()
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({
<>
<CommandEmpty>Nothing matches that.</CommandEmpty>
{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 (
<CommandGroup key={category} heading={label}>
+20 -1
View File
@@ -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) {
</p>
)
}
return <Renderer widget={widget} dashboard={dashboard} />
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.
<ErrorBoundary fallback={<WidgetFailed />} resetKeys={[widget.id]}>
<Renderer widget={widget} dashboard={dashboard} />
</ErrorBoundary>
)
}
function WidgetFailed() {
return (
<p className="flex h-full items-center justify-center gap-2 text-muted-foreground text-sm">
<TriangleAlert className="size-4 shrink-0" aria-hidden />
This tile could not be drawn.
</p>
)
}
+10 -2
View File
@@ -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 {
+10 -1
View File
@@ -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 <OpenLogsPanel {...props} />
}
function OpenLogsPanel({
flow,
open,
node,
+19
View File
@@ -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(
@@ -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(() => {
+4 -2
View File
@@ -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<Theme>(
() =>
(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)
},
}
+4 -3
View File
@@ -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" })
+40
View File
@@ -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.
}
},
}
+11 -2
View File
@@ -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,
+5
View File
@@ -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: () => <ErrorComponent />,
beforeLoad: async () => {
if (!isLoggedIn()) {
throw redirect({