**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.
124 lines
2.8 KiB
TypeScript
124 lines
2.8 KiB
TypeScript
import {
|
|
createContext,
|
|
useCallback,
|
|
useContext,
|
|
useEffect,
|
|
useState,
|
|
} from "react"
|
|
|
|
import { safeStorage } from "@/lib/safeStorage"
|
|
|
|
export type Theme = "dark" | "light" | "system"
|
|
|
|
type ThemeProviderProps = {
|
|
children: React.ReactNode
|
|
defaultTheme?: Theme
|
|
storageKey?: string
|
|
}
|
|
|
|
type ThemeProviderState = {
|
|
theme: Theme
|
|
resolvedTheme: "dark" | "light"
|
|
setTheme: (theme: Theme) => void
|
|
}
|
|
|
|
const initialState: ThemeProviderState = {
|
|
theme: "system",
|
|
resolvedTheme: "light",
|
|
setTheme: () => null,
|
|
}
|
|
|
|
const ThemeProviderContext = createContext<ThemeProviderState>(initialState)
|
|
|
|
export function ThemeProvider({
|
|
children,
|
|
defaultTheme = "system",
|
|
storageKey = "vite-ui-theme",
|
|
...props
|
|
}: ThemeProviderProps) {
|
|
const [theme, setTheme] = useState<Theme>(
|
|
() =>
|
|
(typeof localStorage !== "undefined"
|
|
? (safeStorage.get(storageKey) as Theme)
|
|
: null) || defaultTheme,
|
|
)
|
|
|
|
const getResolvedTheme = useCallback((theme: Theme): "dark" | "light" => {
|
|
if (theme === "system") {
|
|
// Prerendered HTML has no preference to read; the pre-paint script in
|
|
// the document head resolves it before React ever hydrates.
|
|
if (typeof window === "undefined") return "light"
|
|
return window.matchMedia("(prefers-color-scheme: dark)").matches
|
|
? "dark"
|
|
: "light"
|
|
}
|
|
return theme
|
|
}, [])
|
|
|
|
const [resolvedTheme, setResolvedTheme] = useState<"dark" | "light">(() =>
|
|
getResolvedTheme(theme),
|
|
)
|
|
|
|
const updateTheme = useCallback((newTheme: Theme) => {
|
|
const root = window.document.documentElement
|
|
|
|
root.classList.remove("light", "dark")
|
|
|
|
if (newTheme === "system") {
|
|
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
|
|
.matches
|
|
? "dark"
|
|
: "light"
|
|
|
|
root.classList.add(systemTheme)
|
|
return
|
|
}
|
|
|
|
root.classList.add(newTheme)
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
updateTheme(theme)
|
|
setResolvedTheme(getResolvedTheme(theme))
|
|
|
|
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)")
|
|
|
|
const handleChange = () => {
|
|
if (theme === "system") {
|
|
updateTheme("system")
|
|
setResolvedTheme(getResolvedTheme("system"))
|
|
}
|
|
}
|
|
|
|
mediaQuery.addEventListener("change", handleChange)
|
|
|
|
return () => {
|
|
mediaQuery.removeEventListener("change", handleChange)
|
|
}
|
|
}, [theme, updateTheme, getResolvedTheme])
|
|
|
|
const value = {
|
|
theme,
|
|
resolvedTheme,
|
|
setTheme: (theme: Theme) => {
|
|
safeStorage.set(storageKey, theme)
|
|
setTheme(theme)
|
|
},
|
|
}
|
|
|
|
return (
|
|
<ThemeProviderContext.Provider {...props} value={value}>
|
|
{children}
|
|
</ThemeProviderContext.Provider>
|
|
)
|
|
}
|
|
|
|
export const useTheme = () => {
|
|
const context = useContext(ThemeProviderContext)
|
|
|
|
if (context === undefined)
|
|
throw new Error("useTheme must be used within a ThemeProvider")
|
|
|
|
return context
|
|
}
|