From 938863425bf8ad6c17e43b3da128f92cf6d6ab63 Mon Sep 17 00:00:00 2001
From: nsfisis <nsfisis@gmail.com>
Date: Sat, 21 Feb 2026 09:26:44 +0900
Subject: fix(frontend): prevent layout shift in feed sidebar during loading

Show "All feeds" button immediately and load individual feeds
asynchronously via internal Suspense boundary, avoiding the
spinner-to-content shift that occurred with the outer Suspense wrapper.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---
 frontend/src/components/FeedSidebar.tsx | 78 ++++++++++++++++++++++-----------
 1 file changed, 52 insertions(+), 26 deletions(-)

(limited to 'frontend/src/components/FeedSidebar.tsx')

diff --git a/frontend/src/components/FeedSidebar.tsx b/frontend/src/components/FeedSidebar.tsx
index 209be23..3a367f5 100644
--- a/frontend/src/components/FeedSidebar.tsx
+++ b/frontend/src/components/FeedSidebar.tsx
@@ -1,6 +1,8 @@
 import { useAtomValue } from "jotai";
+import { Suspense } from "react";
 import { useLocation, useSearch } from "wouter";
 import { feedsAtom } from "../atoms";
+import { ErrorBoundary } from "./ErrorBoundary";
 
 interface Props {
 	basePath: string;
@@ -13,12 +15,6 @@ export function FeedSidebar({ basePath, isReadView = false }: Props) {
 	const params = new URLSearchParams(search);
 	const selectedFeedId = params.get("feed");
 
-	const { data: allFeeds } = useAtomValue(feedsAtom);
-
-	const feeds = isReadView
-		? allFeeds
-		: allFeeds.filter((feed) => feed.unreadCount > 0);
-
 	const handleSelect = (feedId: string | null) => {
 		if (feedId) {
 			setLocation(`${basePath}?feed=${feedId}`);
@@ -46,27 +42,57 @@ export function FeedSidebar({ basePath, isReadView = false }: Props) {
 						All feeds
 					</button>
 				</li>
-				{feeds.map((feed) => (
-					<li key={feed.id}>
-						<button
-							type="button"
-							onClick={() => handleSelect(feed.id)}
-							className={`flex w-full items-center justify-between rounded-md px-3 py-1.5 text-left text-sm transition-colors ${
-								selectedFeedId === feed.id
-									? "bg-stone-200 font-medium text-stone-900"
-									: "text-stone-600 hover:bg-stone-100"
-							}`}
-						>
-							<span className="min-w-0 truncate">{feed.title}</span>
-							{!isReadView && feed.unreadCount > 0 && (
-								<span className="ml-2 shrink-0 rounded-full bg-sky-100 px-1.5 py-0.5 text-xs font-medium text-sky-700">
-									{feed.unreadCount}
-								</span>
-							)}
-						</button>
-					</li>
-				))}
+				<ErrorBoundary>
+					<Suspense>
+						<FeedListItems
+							isReadView={isReadView}
+							selectedFeedId={selectedFeedId}
+							onSelect={handleSelect}
+						/>
+					</Suspense>
+				</ErrorBoundary>
 			</ul>
 		</nav>
 	);
 }
+
+function FeedListItems({
+	isReadView,
+	selectedFeedId,
+	onSelect,
+}: {
+	isReadView: boolean;
+	selectedFeedId: string | null;
+	onSelect: (feedId: string | null) => void;
+}) {
+	const { data: allFeeds } = useAtomValue(feedsAtom);
+
+	const feeds = isReadView
+		? allFeeds
+		: allFeeds.filter((feed) => feed.unreadCount > 0);
+
+	return (
+		<>
+			{feeds.map((feed) => (
+				<li key={feed.id}>
+					<button
+						type="button"
+						onClick={() => onSelect(feed.id)}
+						className={`flex w-full items-center justify-between rounded-md px-3 py-1.5 text-left text-sm transition-colors ${
+							selectedFeedId === feed.id
+								? "bg-stone-200 font-medium text-stone-900"
+								: "text-stone-600 hover:bg-stone-100"
+						}`}
+					>
+						<span className="min-w-0 truncate">{feed.title}</span>
+						{!isReadView && feed.unreadCount > 0 && (
+							<span className="ml-2 shrink-0 rounded-full bg-sky-100 px-1.5 py-0.5 text-xs font-medium text-sky-700">
+								{feed.unreadCount}
+							</span>
+						)}
+					</button>
+				</li>
+			))}
+		</>
+	);
+}
-- 
cgit v1.3-1-g0d28

