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
@@ -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(() => {