fix(ui): R3 S1 review fixes — don't blank data on transient refetch errors

- The honest-state branch now guards on `isError && !data`, so a background/window-focus refetch
  failure keeps already-visible content instead of replacing the list with a full error screen
  (regression the bare `isError` introduced). Applied to AuditLog, Channels, ChannelDiscovery,
  PlexBrowse, PlexPlaylistView, Messages(people), Feed — matching the guard already used in
  Messages(conversations)/NotificationsPanel/Scheduler.
- Drop the unused DataTable loading/error/onRetry props (every table page uses the outer
  StateMessage branch, not the props) — reverts DataTable to its simple empty block.
- StateMessage announces errors with role=alert/aria-live=assertive (was polite for all tones).
- Remove the now-unused common.empty string (en+hu), left over from the removed QueryState render-prop.
This commit is contained in:
2026-07-22 04:14:46 +02:00
parent d9a08892a5
commit a393fd2fb1
11 changed files with 19 additions and 35 deletions
+1 -1
View File
@@ -54,7 +54,7 @@ export default function AuditLog() {
<div className="p-4 max-w-5xl w-full mx-auto">
<Section card title={t("audit.title")}>
<p className="text-xs text-muted leading-relaxed mb-3">{t("audit.intro")}</p>
{q.isError ? (
{q.isError && !q.data ? (
<StateMessage tone="error" onRetry={() => q.refetch()}>
{t("common.loadError")}
</StateMessage>
+1 -1
View File
@@ -224,7 +224,7 @@ export default function ChannelDiscovery({
</div>
</PageToolbar>
<div className="px-4 pb-4 max-w-[96rem] mx-auto">
{query.isError ? (
{query.isError && !query.data ? (
<StateMessage tone="error" onRetry={() => query.refetch()}>
{t("common.loadError")}
</StateMessage>
+1 -1
View File
@@ -710,7 +710,7 @@ export default function Channels() {
(status chips + pager) rides in the fixed band right above it (controlsInBand). The
card layout swaps the table for a virtualized grid over the same rows (its pager and
status chips ride in the fixed band above instead). */}
{channelsQuery.isError ? (
{channelsQuery.isError && !channelsQuery.data ? (
<StateMessage tone="error" onRetry={() => channelsQuery.refetch()}>
{t("common.loadError")}
</StateMessage>
+2 -18
View File
@@ -4,7 +4,6 @@ import { sortRows, cycleSort, type SortState } from "../lib/columnSort";
import Pager, { hasPagerContent } from "./Pager";
import { PageToolbar } from "./PageShell";
import { ColumnHead, alignClass, filterRows, type Column, type FilterMap } from "./tableHeader";
import { StateMessage } from "./QueryState";
// A reusable, client-side data table: per-column sort + filter (in the header) + pagination,
// with its sort/filter/page state optionally persisted to localStorage so a reload (F5) keeps
@@ -45,9 +44,6 @@ export default function DataTable<T>({
pageSizeOptions = [10, 25, 50, 100],
persistKey,
emptyText,
loading = false,
error = false,
onRetry,
rowClassName,
controlsPosition = "bottom",
controlsLeading,
@@ -65,12 +61,6 @@ export default function DataTable<T>({
pageSizeOptions?: number[];
persistKey?: string;
emptyText?: string;
// Honest states (R3): when the caller's query is loading or errored, pass these so the table
// shows a loading / error(+retry) message instead of an "empty" list that lies about a failed
// fetch. `error` suppresses the empty-text; `onRetry` wires the retry button.
loading?: boolean;
error?: boolean;
onRetry?: () => void;
rowClassName?: (row: T) => string;
controlsPosition?: "top" | "bottom" | "both";
controlsLeading?: ReactNode;
@@ -256,17 +246,11 @@ export default function DataTable<T>({
))}
</div>
{error ? (
<StateMessage tone="error" onRetry={onRetry}>
{t("common.loadError")}
</StateMessage>
) : loading && paged.length === 0 ? (
<StateMessage>{t("common.loading")}</StateMessage>
) : paged.length === 0 ? (
{paged.length === 0 && (
<div className="text-muted py-8 text-center text-sm">
{emptyText ?? t("datatable.empty")}
</div>
) : null}
)}
{controlsPosition === "bottom" && controls}
{controlsPosition === "both" && bottomControls}
+1 -1
View File
@@ -494,7 +494,7 @@ export default function Feed({
}
if (query.isLoading) return <div className="p-8 text-muted">{t("feed.loading")}</div>;
if (query.isError)
if (query.isError && !query.data)
return (
<StateMessage tone="error" onRetry={() => query.refetch()}>
{t("feed.loadError")}
+1 -1
View File
@@ -225,7 +225,7 @@ function Directory({ onPick, onBack }: { onPick: (u: MessageUser) => void; onBac
placeholder={t("messages.searchPeople")}
className="w-full bg-card border border-border rounded-xl px-3 py-2 text-sm outline-none focus:border-accent"
/>
{q.isError ? (
{q.isError && !q.data ? (
<StateMessage tone="error" onRetry={() => q.refetch()}>
{t("common.loadError")}
</StateMessage>
+1 -1
View File
@@ -358,7 +358,7 @@ export default function PlexBrowse() {
</div>
)}
{browseQ.isError ? (
{browseQ.isError && !browseQ.data ? (
<StateMessage tone="error" onRetry={() => browseQ.refetch()}>
{t("common.loadError")}
</StateMessage>
+1 -1
View File
@@ -577,7 +577,7 @@ export default function PlexPlaylistView({
</button>
</div>
{q.isError ? (
{q.isError && !q.data ? (
<StateMessage tone="error" onRetry={() => q.refetch()}>
{t("common.loadError")}
</StateMessage>
+8 -6
View File
@@ -22,8 +22,9 @@ export function StateMessage({
return (
<div
className={`flex flex-col items-center gap-3 py-10 text-sm text-muted ${className}`}
role="status"
aria-live="polite"
// An error is announced promptly (assertive); a plain loading line is polite.
role={tone === "error" ? "alert" : "status"}
aria-live={tone === "error" ? "assertive" : "polite"}
>
<span className="inline-flex items-center gap-2 text-center">
{tone === "error" && <AlertTriangle className="w-4 h-4 shrink-0 text-red-400" />}
@@ -42,7 +43,8 @@ export function StateMessage({
);
}
// The honest-state convention every page now follows: check `isError` BEFORE the empty branch so a
// failed fetch renders this message (with Retry) instead of the page's "empty" text — the R3 bug
// where a backend restart read as "No channels". `StateMessage` is the shared building block;
// pages wire it into their existing loading/empty ternary (and DataTable takes loading/error props).
// The honest-state convention every page now follows: check `isError && <no data>` BEFORE the empty
// branch, so a failed fetch renders this message (with Retry) instead of the page's "empty" text
// — the R3 bug where a backend restart read as "No channels". The `&& <no data>` guard matters: a
// transient refetch error while data is already on screen keeps the data rather than blanking it to
// an error. `StateMessage` is the shared building block pages wire into their loading/empty ternary.
+1 -2
View File
@@ -19,6 +19,5 @@
"demoSharedNotice": "You're in the shared demo account. Everything you do here — playlists, hidden videos, settings — is communal and may be changed by other demo visitors at the same time.",
"backToTop": "Back to top",
"retry": "Retry",
"loadError": "Couldn't load this — check your connection and try again.",
"empty": "Nothing here yet."
"loadError": "Couldn't load this — check your connection and try again."
}
+1 -2
View File
@@ -19,6 +19,5 @@
"demoSharedNotice": "Egy közös demo fiókban vagy. Minden, amit itt csinálsz — lejátszási listák, elrejtett videók, beállítások — közös, és más demo látogatók egyszerre módosíthatják.",
"backToTop": "Vissza a tetejére",
"retry": "Újra",
"loadError": "Nem sikerült betölteni — ellenőrizd a kapcsolatot, és próbáld újra.",
"empty": "Itt még nincs semmi."
"loadError": "Nem sikerült betölteni — ellenőrizd a kapcsolatot, és próbáld újra."
}