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:
@@ -1,11 +1,42 @@
|
|||||||
server {
|
server {
|
||||||
listen 80;
|
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 / {
|
location / {
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html index.htm;
|
index index.html index.htm;
|
||||||
try_files $uri /index.html =404;
|
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;
|
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 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: {
|
const GROUPS: {
|
||||||
category: SearchEntry["category"]
|
category: SearchEntry["category"]
|
||||||
label: string
|
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
|
* 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.
|
* 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.
|
* cmdk does the matching over what is mounted, so the entries are bucketed by
|
||||||
* Cap the groups if an installation ever grows big enough to feel it.
|
* 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({
|
export function GlobalSearch({
|
||||||
open,
|
open,
|
||||||
@@ -79,6 +86,15 @@ export function GlobalSearch({
|
|||||||
|
|
||||||
const entries = data ?? []
|
const entries = data ?? []
|
||||||
const typing = query.trim().length > 0
|
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) => {
|
const go = (entry: SearchEntry) => {
|
||||||
onOpenChange(false)
|
onOpenChange(false)
|
||||||
@@ -144,9 +160,7 @@ export function GlobalSearch({
|
|||||||
<>
|
<>
|
||||||
<CommandEmpty>Nothing matches that.</CommandEmpty>
|
<CommandEmpty>Nothing matches that.</CommandEmpty>
|
||||||
{GROUPS.map(({ category, label, icon: Icon }) => {
|
{GROUPS.map(({ category, label, icon: Icon }) => {
|
||||||
const found = entries.filter(
|
const found = (byCategory.get(category) ?? []).slice(0, GROUP_CAP)
|
||||||
(entry) => entry.category === category,
|
|
||||||
)
|
|
||||||
if (found.length === 0) return null
|
if (found.length === 0) return null
|
||||||
return (
|
return (
|
||||||
<CommandGroup key={category} heading={label}>
|
<CommandGroup key={category} heading={label}>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { TriangleAlert } from "lucide-react"
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
|
import { ErrorBoundary } from "react-error-boundary"
|
||||||
|
|
||||||
import type { WidgetDef } from "@/client"
|
import type { WidgetDef } from "@/client"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
@@ -637,5 +639,22 @@ export function WidgetBody({ widget, dashboard }: WidgetProps) {
|
|||||||
</p>
|
</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 useCustomToast from "@/hooks/useCustomToast"
|
||||||
import { useIsMobile } from "@/hooks/useMobile"
|
import { useIsMobile } from "@/hooks/useMobile"
|
||||||
import { scaleIn } from "@/lib/motion"
|
import { scaleIn } from "@/lib/motion"
|
||||||
|
import { notify } from "@/lib/notificationStore"
|
||||||
|
import { safeStorage } from "@/lib/safeStorage"
|
||||||
import { inCodeEditor, useShortcuts } from "@/lib/shortcuts"
|
import { inCodeEditor, useShortcuts } from "@/lib/shortcuts"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { CanvasTitle } from "./CanvasTitle"
|
import { CanvasTitle } from "./CanvasTitle"
|
||||||
@@ -999,10 +1001,16 @@ function FlowEditorInner({
|
|||||||
}),
|
}),
|
||||||
).catch(() => undefined)
|
).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,
|
CLIPBOARD_KEY,
|
||||||
JSON.stringify({ nodes: picked, sources } satisfies NodeClipboard),
|
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])
|
}, [canvasNodes, definitions, flowName, selectedId, sourceTypes])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1011,7 +1019,7 @@ function FlowEditorInner({
|
|||||||
* the definition is on its way.
|
* the definition is on its way.
|
||||||
*/
|
*/
|
||||||
const pasteNodes = useCallback(() => {
|
const pasteNodes = useCallback(() => {
|
||||||
const stored = localStorage.getItem(CLIPBOARD_KEY)
|
const stored = safeStorage.get(CLIPBOARD_KEY)
|
||||||
if (!stored) return
|
if (!stored) return
|
||||||
let clipboard: NodeClipboard
|
let clipboard: NodeClipboard
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -82,7 +82,16 @@ export function LogsTrigger({
|
|||||||
* Opening it is not only the dock's to do: a failing node points straight at
|
* 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.
|
* 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,
|
flow,
|
||||||
open,
|
open,
|
||||||
node,
|
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. */
|
/** Increments each time the node publishes something. */
|
||||||
export function useNodeEmits(nodeId: string): number {
|
export function useNodeEmits(nodeId: string): number {
|
||||||
return useSyncExternalStore(
|
return useSyncExternalStore(
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useEffect, useState } from "react"
|
|||||||
import type { EventRow, HistoryPoint } from "@/client"
|
import type { EventRow, HistoryPoint } from "@/client"
|
||||||
import type { Range } from "@/components/Common/RangePicker"
|
import type { Range } from "@/components/Common/RangePicker"
|
||||||
import { UplotChart } from "@/components/Common/UplotChart"
|
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 { PANEL_SECTION } from "@/components/Flow/SidePanel"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { cn, dur, si } from "@/lib/utils"
|
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.
|
* the charts are drawn from rather than however far their newest rows reach.
|
||||||
*/
|
*/
|
||||||
export function HealthActivity({ range }: { range: Range }) {
|
export function HealthActivity({ range }: { range: Range }) {
|
||||||
const live = useEngineEvents()
|
const seen = useEngineEventMinute()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const runsAt = useMoment()
|
const runsAt = useMoment()
|
||||||
const failuresAt = 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
|
// 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
|
// ring is full, and a per-event key would let a flapping node restart the
|
||||||
// timer forever without it ever firing.
|
// timer forever without it ever firing. Subscribed to as that scalar rather
|
||||||
const seen = live.length ? Math.floor(live[live.length - 1].ts / 60) : 0
|
// than as the events array, so an event inside a minute already seen does
|
||||||
|
// not re-render the two charts below.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!seen) return
|
if (!seen) return
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
} from "react"
|
} from "react"
|
||||||
|
|
||||||
|
import { safeStorage } from "@/lib/safeStorage"
|
||||||
|
|
||||||
export type Theme = "dark" | "light" | "system"
|
export type Theme = "dark" | "light" | "system"
|
||||||
|
|
||||||
type ThemeProviderProps = {
|
type ThemeProviderProps = {
|
||||||
@@ -37,7 +39,7 @@ export function ThemeProvider({
|
|||||||
const [theme, setTheme] = useState<Theme>(
|
const [theme, setTheme] = useState<Theme>(
|
||||||
() =>
|
() =>
|
||||||
(typeof localStorage !== "undefined"
|
(typeof localStorage !== "undefined"
|
||||||
? (localStorage.getItem(storageKey) as Theme)
|
? (safeStorage.get(storageKey) as Theme)
|
||||||
: null) || defaultTheme,
|
: null) || defaultTheme,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -99,7 +101,7 @@ export function ThemeProvider({
|
|||||||
theme,
|
theme,
|
||||||
resolvedTheme,
|
resolvedTheme,
|
||||||
setTheme: (theme: Theme) => {
|
setTheme: (theme: Theme) => {
|
||||||
localStorage.setItem(storageKey, theme)
|
safeStorage.set(storageKey, theme)
|
||||||
setTheme(theme)
|
setTheme(theme)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
} from "@/client"
|
} from "@/client"
|
||||||
import { liveStore } from "@/components/Flow/liveStore"
|
import { liveStore } from "@/components/Flow/liveStore"
|
||||||
import { isPortal } from "@/lib/portal"
|
import { isPortal } from "@/lib/portal"
|
||||||
|
import { safeStorage } from "@/lib/safeStorage"
|
||||||
import { handleError } from "@/utils"
|
import { handleError } from "@/utils"
|
||||||
import useCustomToast from "./useCustomToast"
|
import useCustomToast from "./useCustomToast"
|
||||||
|
|
||||||
@@ -17,7 +18,7 @@ const isLoggedIn = () => {
|
|||||||
// Under a portal the credential comes with the page rather than from
|
// Under a portal the credential comes with the page rather than from
|
||||||
// storage, and the sign-in already happened there.
|
// storage, and the sign-in already happened there.
|
||||||
if (isPortal()) return true
|
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({
|
const response = await LoginService.loginAccessToken({
|
||||||
formData: data,
|
formData: data,
|
||||||
})
|
})
|
||||||
localStorage.setItem("access_token", response.access_token)
|
safeStorage.set("access_token", response.access_token)
|
||||||
}
|
}
|
||||||
|
|
||||||
const loginMutation = useMutation({
|
const loginMutation = useMutation({
|
||||||
@@ -72,7 +73,7 @@ const useAuth = () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const logout = () => {
|
const logout = () => {
|
||||||
localStorage.removeItem("access_token")
|
safeStorage.remove("access_token")
|
||||||
// Live values belong to the session that was watching them.
|
// Live values belong to the session that was watching them.
|
||||||
liveStore.reset()
|
liveStore.reset()
|
||||||
navigate({ to: "/login" })
|
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 { connectionStore, offlineDetail } from "./lib/connectionStore"
|
||||||
import { notify } from "./lib/notificationStore"
|
import { notify } from "./lib/notificationStore"
|
||||||
import { apiToken, appPath, appRoute, portalConfig } from "./lib/portal"
|
import { apiToken, appPath, appRoute, portalConfig } from "./lib/portal"
|
||||||
|
import { safeStorage } from "./lib/safeStorage"
|
||||||
import { routeTree } from "./routeTree.gen"
|
import { routeTree } from "./routeTree.gen"
|
||||||
|
|
||||||
const portal = portalConfig()
|
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
|
// A paired wall panel has no login screen to go back to — it asks for a
|
||||||
// new code instead.
|
// new code instead.
|
||||||
if (appRoute().startsWith("/panel")) {
|
if (appRoute().startsWith("/panel")) {
|
||||||
localStorage.removeItem("access_token")
|
safeStorage.remove("access_token")
|
||||||
window.location.href = appPath("/panel")
|
window.location.href = appPath("/panel")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -68,7 +69,7 @@ const handleApiError = (error: Error) => {
|
|||||||
window.location.href = `${portal.portalUrl}?reauth=${portal.installationId}`
|
window.location.href = `${portal.portalUrl}?reauth=${portal.installationId}`
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
localStorage.removeItem("access_token")
|
safeStorage.remove("access_token")
|
||||||
window.location.href = appPath("/login")
|
window.location.href = appPath("/login")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -94,6 +95,14 @@ const queryClient = new QueryClient({
|
|||||||
queries: {
|
queries: {
|
||||||
// Retrying an expired session only delays the trip to the login screen.
|
// Retrying an expired session only delays the trip to the login screen.
|
||||||
retry: (count, error) => !isPointlessToRetry(error) && count < 3,
|
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: {
|
mutations: {
|
||||||
retry: false,
|
retry: false,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createFileRoute, Outlet, redirect } from "@tanstack/react-router"
|
import { createFileRoute, Outlet, redirect } from "@tanstack/react-router"
|
||||||
|
|
||||||
import { ConnectionNotice } from "@/components/Common/ConnectionNotice"
|
import { ConnectionNotice } from "@/components/Common/ConnectionNotice"
|
||||||
|
import ErrorComponent from "@/components/Common/ErrorComponent"
|
||||||
import { Footer } from "@/components/Common/Footer"
|
import { Footer } from "@/components/Common/Footer"
|
||||||
import { useFlowSocket } from "@/components/Flow/useFlowSocket"
|
import { useFlowSocket } from "@/components/Flow/useFlowSocket"
|
||||||
import AppSidebar from "@/components/Sidebar/AppSidebar"
|
import AppSidebar from "@/components/Sidebar/AppSidebar"
|
||||||
@@ -13,6 +14,10 @@ import { isLoggedIn } from "@/hooks/useAuth"
|
|||||||
|
|
||||||
export const Route = createFileRoute("/_layout")({
|
export const Route = createFileRoute("/_layout")({
|
||||||
component: 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 () => {
|
beforeLoad: async () => {
|
||||||
if (!isLoggedIn()) {
|
if (!isLoggedIn()) {
|
||||||
throw redirect({
|
throw redirect({
|
||||||
|
|||||||
Reference in New Issue
Block a user