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:
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user