Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .zed/debug.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Project-local debug tasks
//
// For more documentation on how to configure debug tasks,
// see: https://zed.dev/docs/debugger
[]
2 changes: 0 additions & 2 deletions next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,7 @@ const nextConfig: NextConfig = {
},

reactCompiler: true,

cacheComponents: true,

experimental: {
// Forward browser logs to the terminal for easier debugging
browserDebugInfoInTerminal: true,
Expand Down
13 changes: 8 additions & 5 deletions src/app/[locale]/(app)/d/(sidebar)/feeds/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { AppNavigationMenu } from "@/app/[locale]/ui/app-navigation-menu";
import { FeedsSidebar } from "@/app/[locale]/ui/feeds/sidebar/feeds-sidebar";
import { SidebarFeedsProvider } from "@/components/ui/sidebar";
import FeedsUnreadCountProvider from "@/lib/stores/feeds-read-count-context";

export default function DashboardLayout({
children,
Expand All @@ -9,11 +10,13 @@ export default function DashboardLayout({
}): React.JSX.Element {
return (
<SidebarFeedsProvider>
<FeedsSidebar />
<div className="min-h-screen w-full max-w-6xl mx-auto px-4 mb-12 peer-data-[state=expanded]:px-10">
<AppNavigationMenu />
<main>{children}</main>
</div>
<FeedsUnreadCountProvider>
<FeedsSidebar />
<div className="min-h-screen w-full max-w-6xl mx-auto px-4 mb-12 peer-data-[state=expanded]:px-10">
<AppNavigationMenu />
<main>{children}</main>
</div>
</FeedsUnreadCountProvider>
</SidebarFeedsProvider>
);
}
74 changes: 72 additions & 2 deletions src/app/[locale]/ui/feeds/feeds-timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { Link } from "@/i18n/routing";
import { markFeedContentAsRead, markFeedContentAsUnread } from "@/lib/actions";
import type { UserPreferences } from "@/lib/constants";
import { parsing } from "@/lib/parsing.client";
import { useFeedsUnereadCount } from "@/lib/stores/feeds-read-count-context";
import { searchParamsState } from "@/lib/stores/search-params-states";
import { userfeedsfuncs } from "@/lib/userfeeds-funcs";
import { cn } from "@/lib/utils";
Expand All @@ -29,6 +30,16 @@ export function FeedsTimeline({
urlKeys: searchParamsState.urlKeys,
});

const feedUnreadCounts: Map<number, number> = new Map();
for (const item of timeline) {
if (item.readAt === null) {
feedUnreadCounts.set(
item.feedId,
(feedUnreadCounts.get(item.feedId) ?? 0) + 1,
);
}
}

const items = timeline
.filter((el) => {
if (selectedFeed === searchParamsState.DEFAULT_FEED) return true;
Expand All @@ -44,40 +55,78 @@ export function FeedsTimeline({
return (
<section className={cn("wrap-anywhere", SPACING.LG)}>
{items.map((item) => {
return <Item item={item} key={`${item.id}`} />;
return (
<Item
item={item}
feedUnreadCounts={feedUnreadCounts.get(item.feedId) ?? 0}
key={`${item.id}`}
/>
);
})}
</section>
);
}

const Item = ({ item }: { item: FeedTimeline }): React.JSX.Element => {
const Item = ({
item,
feedUnreadCounts,
}: {
item: FeedTimeline;
/**
* feedUnreadCounts is the initial number of items left to read in a feed.
* Used for optimistic updates to sync with the sidebar through the context.
*/
feedUnreadCounts: number;
}): React.JSX.Element => {
const t = useTranslations("rssFeed");
const locale = useLocale();

const [isRead, setIsRead] = useOptimistic(item.readAt !== null);
const feedsReadCount = useFeedsUnereadCount();

const handleMarkAsRead = async (
feedId: number,
feedContentId: number,
): Promise<void> => {
feedsReadCount.setOptimisticUnread(
feedId,
feedUnreadCounts - 1,
feedUnreadCounts,
);

startTransition(async () => {
try {
setIsRead(true);
const res = await markFeedContentAsRead(feedId, feedContentId);
Comment on lines 84 to 100

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Guard these optimistic toggles against re-entry.

Lines 91-95 and 144-148 apply the unread delta on every call, but handleMarkAsRead() is also invoked from the link click at Line 225. Opening an already-read item—or double-clicking before the first mutation settles—will enqueue another delta even if the backend write is a no-op, which can leave the sidebar unread count off by one. Add a per-item in-flight / desired-state guard before setOptimisticUnread().

🛠️ Possible fix
-import { startTransition, useOptimistic } from "react";
+import { startTransition, useOptimistic, useState } from "react";
...
 	const [isRead, setIsRead] = useOptimistic(item.readAt !== null);
+	const [isTogglingRead, setIsTogglingRead] = useState(false);
...
 	const handleMarkAsRead = async (
 		feedId: number,
 		feedContentId: number,
 	): Promise<void> => {
+		if (isRead || isTogglingRead) return;
+		setIsTogglingRead(true);
 		feedsReadCount.setOptimisticUnread(
 			feedId,
 			feedUnreadCounts - 1,
 			feedUnreadCounts,
 		);

 		startTransition(async () => {
 			try {
 				setIsRead(true);
 				const res = await markFeedContentAsRead(feedId, feedContentId);
 				...
 			} catch (err) {
 				...
+			} finally {
+				setIsTogglingRead(false);
 			}
 		});
 	};

 	const handleMarkAsUnread = async (
 		feedId: number,
 		feedContentId: number,
 	): Promise<void> => {
+		if (!isRead || isTogglingRead) return;
+		setIsTogglingRead(true);
 		feedsReadCount.setOptimisticUnread(
 			feedId,
 			feedUnreadCounts + 1,
 			feedUnreadCounts,
 		);

 		startTransition(async () => {
 			try {
 				setIsRead(false);
 				const res = await markFeedContentAsUnread(feedId, feedContentId);
 				...
 			} catch (err) {
 				...
+			} finally {
+				setIsTogglingRead(false);
 			}
 		});
 	};

Also applies to: 140-148

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/`[locale]/ui/feeds/feeds-timeline.tsx around lines 84 - 100, The
optimistic unread delta is applied regardless of current/desired state, causing
double-decrements; add a per-item guard (e.g., an inFlight map or desiredState
flag keyed by feedContentId) in handleMarkAsRead (and the other location that
calls feedsReadCount.setOptimisticUnread) to check and short-circuit if this
content is already marked read or is currently being processed, then only call
feedsReadCount.setOptimisticUnread when the guard transitions from not-in-flight
to in-flight; also ensure the guard is cleared on both success and failure (and
when setIsRead(true) is applied) to keep state consistent.


if (res.errors) {
feedsReadCount.setOptimisticUnread(
feedId,
feedUnreadCounts + 1,
feedUnreadCounts,
);
setIsRead(false);
toast.error([res.errors.feedId, res.errors.feedContentId].join(", "));
return;
}

if (res.errI18Key) {
feedsReadCount.setOptimisticUnread(
feedId,
feedUnreadCounts + 1,
feedUnreadCounts,
);
setIsRead(false);
// biome-ignore lint/suspicious/noExplicitAny: valid type.
toast.error(t(res.errI18Key as any));
return;
}
} catch (err) {
feedsReadCount.setOptimisticUnread(
feedId,
feedUnreadCounts + 1,
feedUnreadCounts,
);
setIsRead(false);
if (err instanceof Error) {
toast.error(err.message);
Expand All @@ -92,24 +141,45 @@ const Item = ({ item }: { item: FeedTimeline }): React.JSX.Element => {
feedId: number,
feedContentId: number,
): Promise<void> => {
feedsReadCount.setOptimisticUnread(
feedId,
feedUnreadCounts + 1,
feedUnreadCounts,
);
startTransition(async () => {
try {
setIsRead(false);
const res = await markFeedContentAsUnread(feedId, feedContentId);
// Prevent double subtraction.

if (res.errors) {
feedsReadCount.setOptimisticUnread(
feedId,
feedUnreadCounts - 1,
feedUnreadCounts,
);
setIsRead(true);
toast.error([res.errors.feedId, res.errors.feedContentId].join(", "));
return;
}

if (res.errI18Key) {
feedsReadCount.setOptimisticUnread(
feedId,
feedUnreadCounts - 1,
feedUnreadCounts,
);
setIsRead(true);
// biome-ignore lint/suspicious/noExplicitAny: valid type.
toast.error(t(res.errI18Key as any));
return;
}
} catch (err) {
feedsReadCount.setOptimisticUnread(
feedId,
feedUnreadCounts - 1,
feedUnreadCounts,
);
setIsRead(true);
Comment thread
mtlaso marked this conversation as resolved.
if (err instanceof Error) {
toast.error(err.message);
Expand Down
28 changes: 15 additions & 13 deletions src/app/[locale]/ui/feeds/sidebar/feeds-sidebar-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import { RestrictToVerticalAxis } from "@dnd-kit/abstract/modifiers";
import { DragDropProvider, PointerSensor, useDroppable } from "@dnd-kit/react";
import { useTranslations } from "next-intl";
import { startTransition, use, useOptimistic, useRef } from "react";
import { startTransition, use, useOptimistic } from "react";
Comment thread
coderabbitai[bot] marked this conversation as resolved.
import { toast } from "sonner";
import { FeedsSidebarFolder } from "@/app/[locale]/ui/feeds/sidebar/feeds-sidebar-folder";
import { FeedsSidebarItem } from "@/app/[locale]/ui/feeds/sidebar/feeds-sidebar-item";
Expand All @@ -21,6 +21,7 @@ import {
type FeedWithContentsCount,
UNCATEGORIZED_FEEDS_FOLDER_ID,
} from "@/lib/constants";
import { useFeedsUnereadCount } from "@/lib/stores/feeds-read-count-context";

type Props = {
userFeedsGroupedByFolderPromise: Promise<FeedFolder[]>;
Expand All @@ -34,7 +35,6 @@ export function FeedsSidebarContent({
FeedFolder[]
>(_userFeedsGroupedByFolder);
// Sauvegarder les éléments au cas si optimistic delete ne fonctionne pas.
const rollbackSnapshotRef = useRef<FeedFolder[] | null>(null);
const t = useTranslations("rssFeed");

const handleOnMove = (
Expand Down Expand Up @@ -81,9 +81,6 @@ export function FeedsSidebarContent({
// Trouver le dossier orignal, puis mettre id par défaut (-1).
setUserFeedsGroupedByFolder((prev) => {
const folders: FeedFolder[] = structuredClone(prev);
// Sauvegarder les éléments au cas si optimistic delete ne fonctionne pas.
rollbackSnapshotRef.current = folders;

// Trouver le dossier.
const deletedFolder = folders.find((item) => item.folderId === id);
if (!deletedFolder) return prev;
Expand Down Expand Up @@ -159,7 +156,7 @@ export function FeedsSidebarContent({
}

// This needs to be it's own component so it can be a droppable zone.
// The drop mechanism wouldn't work if it's a direct descendant of the parent component.
// The drop mechanism wouldn't work if it's directly code in the parent component.
function Content({
userFeedsGroupedByFolder,
handleOnRemove,
Expand All @@ -168,17 +165,22 @@ function Content({
handleOnRemove: (id: number) => void;
}) {
const t = useTranslations("rssFeed");
const feedsReadCount = useFeedsUnereadCount();

const totalFeeds = userFeedsGroupedByFolder.values().reduce((acc, folder) => {
return acc + folder.feeds.length;
}, 0);
const totalFeedsContents = userFeedsGroupedByFolder
.values()
.reduce(
(acc, folder) =>
acc + folder.feeds.reduce((s, f) => s + f.contentsCount, 0),
0,
);

const totalFeedsContents = userFeedsGroupedByFolder.values().reduce(
(acc, folder) =>
acc +
folder.feeds.reduce((sacc, f) => {
const serverUnread = f.contentsCount - f.readContentsCount;
const resolved = feedsReadCount.getUnreadCount(f.id, serverUnread);
return sacc + resolved;
}, 0),
0,
);
const { ref, isDropTarget } = useDroppable({
id: UNCATEGORIZED_FEEDS_FOLDER_ID,
});
Expand Down
9 changes: 8 additions & 1 deletion src/app/[locale]/ui/feeds/sidebar/feeds-sidebar-folder.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {
type FeedWithContentsCount,
} from "@/lib/constants";
import { useIsMobile } from "@/lib/hooks/use-mobile";
import { useFeedsUnereadCount } from "@/lib/stores/feeds-read-count-context";
import { searchParamsState } from "@/lib/stores/search-params-states";

type Props = {
Expand Down Expand Up @@ -114,6 +115,7 @@ function Draggable({ feed }: { feed: FeedWithContentsCount }) {
urlKeys: searchParamsState.urlKeys,
},
);
const feedsReadCount = useFeedsUnereadCount();

return (
<SidebarMenuItem
Expand Down Expand Up @@ -141,7 +143,12 @@ function Draggable({ feed }: { feed: FeedWithContentsCount }) {
isDragging={isDragging}
/>
<span className="truncate">{feed.title}</span>
<SidebarMenuBadge>{feed.contentsCount}</SidebarMenuBadge>
<SidebarMenuBadge>
{feedsReadCount.getUnreadCount(
feed.id,
feed.contentsCount - feed.readContentsCount,
)}
</SidebarMenuBadge>
</button>
</SidebarMenuSubButton>
</SidebarMenuItem>
Expand Down
9 changes: 8 additions & 1 deletion src/app/[locale]/ui/feeds/sidebar/feeds-sidebar-item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
} from "@/components/ui/sidebar";
import { FeedStatusType, type FeedWithContentsCount } from "@/lib/constants";
import { useIsMobile } from "@/lib/hooks/use-mobile";
import { useFeedsUnereadCount } from "@/lib/stores/feeds-read-count-context";
import { searchParamsState } from "@/lib/stores/search-params-states";

type Props = {
Expand All @@ -30,6 +31,7 @@ export function FeedsSidebarItem({ feed }: Props) {
id: feed.id,
data: feed,
});
const feedsReadCount = useFeedsUnereadCount();

return (
<SidebarMenuItem
Expand Down Expand Up @@ -57,7 +59,12 @@ export function FeedsSidebarItem({ feed }: Props) {
isDragging={isDragging}
/>
<span className="truncate min-w-0">{feed.title}</span>
<SidebarMenuBadge>{feed.contentsCount}</SidebarMenuBadge>
<SidebarMenuBadge>
{feedsReadCount.getUnreadCount(
feed.id,
feed.contentsCount - feed.readContentsCount,
)}
</SidebarMenuBadge>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
</button>
</SidebarFeedsMenuButton>
</SidebarMenuItem>
Expand Down
3 changes: 3 additions & 0 deletions src/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@ export type FeedWithContentsCount = {
url: string;
status: FeedStatusType;
folderId: FolderId;
/** Total number of items. */
contentsCount: number;
/** Number of items that were read. */
readContentsCount: number;
};

export enum SortOptions {
Expand Down
Loading
Loading