From 36eda465717086247e00981ddd50e4a0075912e5 Mon Sep 17 00:00:00 2001 From: Josh Cain Date: Wed, 29 Jul 2026 13:47:07 -0400 Subject: [PATCH 1/2] Keep the last good panel data when a refresh fails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit usePolled documents that "a failed refresh keeps the last good data on screen", but it could only honour that for transport failures. An upstream declining arrives as a *successful* 200 carrying `available: false`, so setData() overwrote the last good payload with it and PanelBody fell straight through to PanelEmpty. The result was that one failed poll blanked a panel until the server's TTL lapsed — up to 60s for the calendar, 30s for requests and activity — and memoize would go on serving that cached failure for the rest of the TTL even after the upstream recovered. PanelBody now remembers the most recent available payload and keeps rendering it when a later response is unavailable, above a marker that names the reason and carries the same hint PanelEmpty would. Staleness is stated rather than hidden: showing stale numbers silently would be worse than showing none. Left memoize alone deliberately. It is generic over T and knows nothing about Result, and caching failures is what stops a dead upstream being hit every five seconds — the display decision belongs on the client, where Result is already understood. Verified end to end against a live Seerr behind a toggleable proxy: good data renders, breaking the upstream keeps the rows and adds "Showing last known data — HTTP 503", and healing it clears the marker. Co-Authored-By: Claude Opus 5 --- dashboard/web/src/components/Panel.tsx | 97 +++++++++++++++++++++++--- 1 file changed, 86 insertions(+), 11 deletions(-) diff --git a/dashboard/web/src/components/Panel.tsx b/dashboard/web/src/components/Panel.tsx index 5873fe2..4f8fa66 100644 --- a/dashboard/web/src/components/Panel.tsx +++ b/dashboard/web/src/components/Panel.tsx @@ -1,5 +1,5 @@ -import type { ReactNode } from 'react'; -import { Info } from '@phosphor-icons/react'; +import { useRef, type ReactNode } from 'react'; +import { Info, WarningCircle } from '@phosphor-icons/react'; import type { Result } from '../types'; @@ -69,6 +69,58 @@ export function PanelEmpty({ result }: { result: { reason: string; hint?: string ); } +/** + * Shown above a panel's content when the newest response was unavailable but an + * earlier one wasn't. + * + * An upstream declining once is not a reason to throw away data that is seconds + * old — but it is a reason to say so, because silently showing stale numbers is + * worse than showing none. Carries the same reason and hint `PanelEmpty` would, + * so the fix stays discoverable without the panel going blank. + */ +export function PanelStale({ result }: { result: { reason: string; hint?: string } }) { + return ( +
+ +
+ Showing last known data — {result.reason} + {result.hint && ( +
+ {result.hint} +
+ )} +
+
+ ); +} + +/** + * Remembers the most recent payload that was actually available. + * + * Assigning during render is safe here because it's derived purely from props + * and is idempotent — the same `data` always produces the same assignment, so a + * double render under StrictMode can't skew it. + */ +function useLastAvailable(data: Result | null): T | null { + const lastAvailable = useRef(null); + if (data?.available) lastAvailable.current = data; + return lastAvailable.current; +} + /** Placeholder while a panel's first request is in flight. */ export function PanelLoading() { return ( @@ -98,6 +150,20 @@ export function PanelBody({ empty?: string; children: (value: T) => ReactNode; }) { + const lastAvailable = useLastAvailable(data); + + const render = (value: T) => { + const rendered = children(value); + if (empty && Array.isArray(rendered) && rendered.length === 0) { + return ( + + {empty} + + ); + } + return <>{rendered}; + }; + if (loading && !data) return ; // A transport failure leaves `data` null with `loading` false. Reporting that // as "No response yet" would imply the request is still coming. @@ -112,15 +178,24 @@ export function PanelBody({ /> ); } - if (!data.available) return ; - const rendered = children(data); - if (empty && Array.isArray(rendered) && rendered.length === 0) { - return ( - - {empty} - - ); + if (!data.available) { + // An upstream blip arrives as a *successful* response carrying + // `available: false`, so `usePolled` can't tell it from real data and + // replaces the last good payload with it. Without this branch a single + // failed poll blanks the panel until the server-side TTL lapses — up to a + // minute for the calendar — which is exactly what "a failed refresh keeps + // the last good data on screen" is supposed to prevent. + if (lastAvailable) { + return ( + <> + + {render(lastAvailable)} + + ); + } + return ; } - return <>{rendered}; + + return render(data); } From f182b495026a8943c7096a01a0ac8ee117e11e0f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 2 Aug 2026 23:00:13 +0000 Subject: [PATCH 2/2] fix: move stale panel ref updates into an effect --- dashboard/web/src/components/Panel.tsx | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/dashboard/web/src/components/Panel.tsx b/dashboard/web/src/components/Panel.tsx index 4f8fa66..fe3b022 100644 --- a/dashboard/web/src/components/Panel.tsx +++ b/dashboard/web/src/components/Panel.tsx @@ -1,4 +1,4 @@ -import { useRef, type ReactNode } from 'react'; +import { useEffect, useRef, type ReactNode } from 'react'; import { Info, WarningCircle } from '@phosphor-icons/react'; import type { Result } from '../types'; @@ -108,16 +108,12 @@ export function PanelStale({ result }: { result: { reason: string; hint?: string ); } -/** - * Remembers the most recent payload that was actually available. - * - * Assigning during render is safe here because it's derived purely from props - * and is idempotent — the same `data` always produces the same assignment, so a - * double render under StrictMode can't skew it. - */ +/** Remembers the most recent payload that was actually available. */ function useLastAvailable(data: Result | null): T | null { const lastAvailable = useRef(null); - if (data?.available) lastAvailable.current = data; + useEffect(() => { + if (data?.available) lastAvailable.current = data; + }, [data]); return lastAvailable.current; }