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