From b3cfe948101526b0aaba0d574b228fe6ef5da71f Mon Sep 17 00:00:00 2001 From: Cesar Alcazar Date: Tue, 20 Oct 2026 08:09:51 -0600 Subject: [PATCH] le8i2digy: Keep the posts and show an inline error when a refresh fails --- src/components/Feed.tsx | 9 +++++++-- src/components/FeedView.tsx | 7 +++++-- src/lib/feed-model.ts | 5 +++++ src/styles/feed.css | 1 + tests/feed-view.test.ts | 24 +++++++++++++++++++++++- 5 files changed, 41 insertions(+), 5 deletions(-) diff --git a/src/components/Feed.tsx b/src/components/Feed.tsx index d1a8f2a..3dafa9b 100644 --- a/src/components/Feed.tsx +++ b/src/components/Feed.tsx @@ -3,7 +3,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { fetchFeedPage, FeedLoadError } from "@/lib/feed-client"; import { applyLikeOutcome, LikeError, sendLike } from "@/lib/like-client"; -import { mergePosts, toCardPosts, upgradeVideos, type CardPost } from "@/lib/feed-model"; +import { firstLoadFailure, mergePosts, toCardPosts, upgradeVideos, type CardPost } from "@/lib/feed-model"; import { PostError, publishAndConfirm } from "@/lib/post-client"; import { DeleteError, sendDelete } from "@/lib/delete-client"; import type { ComposerProps } from "./Composer"; @@ -26,6 +26,7 @@ export function Feed({ const [nextCursor, setNextCursor] = useState(null); const [loadingMore, setLoadingMore] = useState(false); const [refreshing, setRefreshing] = useState(false); + const [refreshFailed, setRefreshFailed] = useState(false); const [loadMoreFailed, setLoadMoreFailed] = useState(false); const [pending, setPending] = useState([]); const [likeBusy, setLikeBusy] = useState>(new Set()); @@ -59,6 +60,7 @@ export function Feed({ const loadFirst = useCallback(async (quiet = false) => { if (busy.current) return; busy.current = true; + setRefreshFailed(false); if (quiet) setRefreshing(true); else setStatus("loading"); setLoadMoreFailed(false); @@ -70,7 +72,9 @@ export function Feed({ setStatus("ready"); } catch (e) { fail(e); - setStatus("error"); + const out = firstLoadFailure(quiet); + if (out.fullError) setStatus("error"); + setRefreshFailed(out.refreshFailed); } finally { busy.current = false; setRefreshing(false); @@ -262,6 +266,7 @@ export function Feed({ onShowNew={showNew} onRefresh={refresh} refreshing={refreshing} + refreshFailed={refreshFailed} onToggleLike={(p) => void toggleLike(p)} likeBusy={likeBusy} likeErrors={likeErrors} diff --git a/src/components/FeedView.tsx b/src/components/FeedView.tsx index 74d7ff5..99e4d2c 100644 --- a/src/components/FeedView.tsx +++ b/src/components/FeedView.tsx @@ -17,6 +17,8 @@ export interface FeedViewProps { /** Reloads the first page; the header shows the button when it is given. */ onRefresh?: () => void; refreshing?: boolean; + /** The last refresh failed; the posts stay and a small message shows in the header. */ + refreshFailed?: boolean; onToggleLike?: (post: CardPost) => void; likeBusy?: ReadonlySet; likeErrors?: ReadonlySet; @@ -87,11 +89,12 @@ export function RefreshButton({ onRefresh, busy }: { onRefresh: () => void; busy ); } -function FeedPanel({ children, composer, onRefresh, busy }: { children: ReactNode; composer?: ReactNode; onRefresh?: () => void; busy?: boolean }) { +function FeedPanel({ children, composer, onRefresh, busy, refreshFailed }: { children: ReactNode; composer?: ReactNode; onRefresh?: () => void; busy?: boolean; refreshFailed?: boolean }) { return (

Home feed

+ {refreshFailed ? Could not refresh. Try again. : null} {onRefresh ? : null}
{composer} @@ -121,7 +124,7 @@ export function FeedView(p: FeedViewProps) { ); } return ( - : null} onRefresh={p.onRefresh} busy={p.refreshing}> + : null} onRefresh={p.onRefresh} busy={p.refreshing} refreshFailed={p.refreshFailed}>
{p.newCount > 0 ? (
diff --git a/src/lib/feed-model.ts b/src/lib/feed-model.ts index d24faef..eaf0d82 100644 --- a/src/lib/feed-model.ts +++ b/src/lib/feed-model.ts @@ -194,6 +194,11 @@ export function upgradeVideos(current: CardPost[], fresh: CardPost[]): CardPost[ return changed ? next : current; } +/** A failed first-page load: a refresh keeps the posts and flags an inline error; an initial load shows the full error state. */ +export function firstLoadFailure(refresh: boolean): { fullError: boolean; refreshFailed: boolean } { + return { fullError: !refresh, refreshFailed: refresh }; +} + /** "1 comment", "0 comments", "2 comments". */ export function commentsLabel(count: number): string { return count === 1 ? "1 comment" : `${count} comments`; diff --git a/src/styles/feed.css b/src/styles/feed.css index c7e0d3e..92a7b00 100644 --- a/src/styles/feed.css +++ b/src/styles/feed.css @@ -69,3 +69,4 @@ @media (prefers-reduced-motion: reduce) { .refresh-icon-spin { animation: none !important; } } +.feed-refresh-error { margin-left: auto; font-size: 13px; color: var(--danger, #e5484d); } diff --git a/tests/feed-view.test.ts b/tests/feed-view.test.ts index 3f229f5..4c51248 100644 --- a/tests/feed-view.test.ts +++ b/tests/feed-view.test.ts @@ -3,7 +3,7 @@ import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { FeedView, RefreshButton, type FeedViewProps } from "@/components/FeedView"; import { fetchFeedPage, FeedLoadError } from "@/lib/feed-client"; -import { commentsLabel, formatDuration, mergePosts, relativeTime, toCardPost, toCardPosts, upgradeVideos } from "@/lib/feed-model"; +import { commentsLabel, firstLoadFailure, formatDuration, mergePosts, relativeTime, toCardPost, toCardPosts, upgradeVideos } from "@/lib/feed-model"; const NOW = Date.parse("2026-10-19T12:00:00Z"); const post = (id: string, publishedAt: string, extra: Record = {}) => ({ @@ -245,3 +245,25 @@ describe("comments label", () => { expect(html).not.toContain("1 comments"); }); }); + +describe("failed refresh versus failed first load", () => { + it("a failed refresh keeps the posts and shows an inline error with a usable button", () => { + const posts = toCardPosts([post("1", "2026-10-19T11:00:00Z")]); + const html = render({ posts, onRefresh: () => {}, refreshFailed: true }); + expect(html).toContain("Could not refresh. Try again."); + expect(html).toContain("text 1"); + expect(html).not.toContain("The feed did not load."); + expect(html).not.toMatch(/]*disabled[^>]*aria-label="Refresh feed"|]*aria-label="Refresh feed"[^>]*disabled/); + }); + + it("a failed first load shows the full error state", () => { + const html = render({ status: "error", onRefresh: () => {} }); + expect(html).toContain("The feed did not load."); + expect(html).not.toContain("Could not refresh"); + }); + + it("the loader maps a failure to the right outcome", () => { + expect(firstLoadFailure(true)).toEqual({ fullError: false, refreshFailed: true }); + expect(firstLoadFailure(false)).toEqual({ fullError: true, refreshFailed: false }); + }); +});