feat: afficher le nombre de contenu non lus dans la barre latérale dans /feeds#327
feat: afficher le nombre de contenu non lus dans la barre latérale dans /feeds#327
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
|
Warning Rate limit exceeded
⌛ How to resolve this issue?After the wait time has elapsed, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout. Please see our FAQ for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (1)
📝 WalkthroughWalkthroughAdds server-side per-feed read counts and a client-side FeedsUnreadCountProvider for optimistic per-feed unread deltas; updates DAL/type to include Changes
Sequence Diagram(s)sequenceDiagram
autonumber
participant UI as Client/UI
participant Provider as FeedsUnreadCountProvider
participant API as Server/API
participant DAL as Data Access Layer
participant DB as Database
UI->>API: GET /user/feeds (request feeds + counts)
API->>DAL: getUserFeedsWithContentsCount(userId)
DAL->>DB: SELECT feeds, COUNT(contents) AS contentsCount,\nCOUNT(users_feeds_read_content.readAt) AS readContentsCount\nLEFT JOIN users_feeds_read_content ...
DB-->>DAL: feed rows (contentsCount, readContentsCount)
DAL-->>API: feed objects
API-->>UI: JSON with feeds (contentsCount, readContentsCount)
UI->>Provider: initialize store (no optimistic deltas)
UI->>UI: render badges = (contentsCount - readContentsCount) + Provider.getUnreadCount(feedId, serverUnread)
sequenceDiagram
autonumber
participant UI as Client/UI
participant Provider as FeedsUnreadCountProvider
participant API as Server/API
UI->>Provider: setOptimisticUnread(feedId, optimistic=-1, baseline=serverUnread) rgba(0,128,0,0.5)
UI->>API: POST /feedContent/{id}/markRead
API-->>UI: {ok} or {error}
alt success
UI->>Provider: clearOptimistic(feedId)
else error
UI->>Provider: setOptimisticUnread(feedId, optimistic=0, baseline=serverUnread) rgba(255,0,0,0.5)
UI->>UI: show toast error
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
📝 Coding Plan
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
…pas dans des dossiers.
|
Vercel Preview URL 🚀 : https://bocal-gqkizea7g-euuuuhs-projects.vercel.app |
…e latérale (titre).
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (1)
src/lib/constants.ts (1)
25-26: Typo in JSDoc comment."where" should be "were".
📝 Proposed fix
- /** Number of items that where read. */ + /** Number of items that were read. */ readContentsCount: number;🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/lib/constants.ts` around lines 25 - 26, The JSDoc for the readContentsCount property contains a typo ("where" -> "were"); update the comment above the readContentsCount field in constants.ts (the JSDoc for the readContentsCount property) to read "Number of items that were read." so the documentation is correct.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/lib/dal.ts`:
- Around line 231-234: The leftJoin against usersFeedsReadContent is missing a
user filter so read counts aggregate across all users; update the join in the
query that uses usersFeedsReadContent and feedsContent to include
eq(usersFeedsReadContent.userId, userId) (or the appropriate request/user
parameter) so reads are scoped per-user, and ensure the surrounding function
(same pattern as getUserFeedsWithContentsCount) accepts and passes that userId
into the query.
- Around line 175-178: The leftJoin on usersFeedsReadContent currently only
matches eq(usersFeedsReadContent.feedContentId, feedsContent.id) and thus
aggregates reads across all users; update the join condition to include the
current user's filter by combining conditions with
and(eq(usersFeedsReadContent.feedContentId, feedsContent.id),
eq(usersFeedsReadContent.userId, userId)) (import and from drizzle-orm if
missing) and ensure the containing function (where leftJoin is called)
accepts/receives a userId parameter and uses it in the join so read counts are
scoped to the current user.
---
Nitpick comments:
In `@src/lib/constants.ts`:
- Around line 25-26: The JSDoc for the readContentsCount property contains a
typo ("where" -> "were"); update the comment above the readContentsCount field
in constants.ts (the JSDoc for the readContentsCount property) to read "Number
of items that were read." so the documentation is correct.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 36c9d14d-9893-4cb4-a35a-1c3bda50e1e0
📒 Files selected for processing (4)
src/app/[locale]/ui/feeds/sidebar/feeds-sidebar-content.tsxsrc/app/[locale]/ui/feeds/sidebar/feeds-sidebar-folder.tsxsrc/lib/constants.tssrc/lib/dal.ts
| .leftJoin( | ||
| usersFeedsReadContent, | ||
| eq(usersFeedsReadContent.feedContentId, feedsContent.id), | ||
| ) |
There was a problem hiding this comment.
Same issue: Missing userId filter in join.
This query has the same problem as getUserFeedsWithContentsCount — it will count read items from all users.
🐛 Proposed fix
.leftJoin(
usersFeedsReadContent,
- eq(usersFeedsReadContent.feedContentId, feedsContent.id),
+ and(
+ eq(usersFeedsReadContent.feedContentId, feedsContent.id),
+ eq(usersFeedsReadContent.userId, user.user.id),
+ ),
)📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| .leftJoin( | |
| usersFeedsReadContent, | |
| eq(usersFeedsReadContent.feedContentId, feedsContent.id), | |
| ) | |
| .leftJoin( | |
| usersFeedsReadContent, | |
| and( | |
| eq(usersFeedsReadContent.feedContentId, feedsContent.id), | |
| eq(usersFeedsReadContent.userId, user.user.id), | |
| ), | |
| ) |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/lib/dal.ts` around lines 231 - 234, The leftJoin against
usersFeedsReadContent is missing a user filter so read counts aggregate across
all users; update the join in the query that uses usersFeedsReadContent and
feedsContent to include eq(usersFeedsReadContent.userId, userId) (or the
appropriate request/user parameter) so reads are scoped per-user, and ensure the
surrounding function (same pattern as getUserFeedsWithContentsCount) accepts and
passes that userId into the query.
✅ Addressed in commit 91ed416
There was a problem hiding this comment.
what about the where clause? it does that right?
There was a problem hiding this comment.
@mtlaso, unfortunately the where clause doesn't cover this case. Here's why:
The where clause filters usersFeeds to only include rows belonging to the current user — meaning it correctly scopes which feeds are returned. However, usersFeedsReadContent is a completely separate table with its own userId column tracking which user marked a piece of content as read. The LEFT JOIN condition only matches on feedContentId, so it will pull in every user's read records for that content item.
Consider this concrete example:
- Feed F has content item C1.
- User A read C1 → row
(userId=A, feedContentId=C1)inusersFeedsReadContent. - User B also read C1 → row
(userId=B, feedContentId=C1)inusersFeedsReadContent.
When User A runs the query, the where clause correctly returns only Feed F (because usersFeeds.userId = A). But the LEFT JOIN on usersFeedsReadContent matches both (A, C1) and (B, C1), so count(usersFeedsReadContent.readAt) yields 2 instead of the correct 1.
The fix is still needed — the join must be scoped to the current user:
.leftJoin(
usersFeedsReadContent,
- eq(usersFeedsReadContent.feedContentId, feedsContent.id),
+ and(
+ eq(usersFeedsReadContent.feedContentId, feedsContent.id),
+ eq(usersFeedsReadContent.userId, user.user.id),
+ ),
)This same fix should also be applied to the getUserFeedsWithContentsCount query for the same reason.
🧠 Learnings used
Learnt from: mtlaso
Repo: mtlaso/bocal PR: 253
File: src/app/[locale]/ui/feeds/sidebar/feeds-sidebar-content.tsx:31-39
Timestamp: 2025-08-09T01:11:03.087Z
Learning: In the bocal application, the `getUserFeedsGroupedByFolder()` function returns a `Map<string | null, FeedFolder>` where the key is either a string (representing the folder name) or null (representing uncategorized feeds). When rendering in `FeedsSidebarContent`, checking `if (key)` correctly distinguishes between named folders (truthy string) and uncategorized feeds (falsy null).
There was a problem hiding this comment.
Okay, I guess this wouldn't be a problem if usersFeedsReadContent had it's own primary key (not composite)
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Find the schema definition for usersFeedsReadContent
rg -n "usersFeedsReadContent" --type=ts -A 15 -B 2
Length of output: 12344
@mtlaso, actually a standalone (non-composite) PK wouldn't help here — the problem is independent of primary key structure.
Here's why: the PK only governs row uniqueness, not how many rows exist per feedContentId. Both designs end up with the same data:
| id (standalone PK) | userId | feedContentId |
|---|---|---|
| 1 | A | C1 |
| 2 | B | C1 |
vs.
| userId (composite PK) | feedContentId | ... |
|---|---|---|
| A | C1 | ... |
| B | C1 | ... |
In both cases, joining on feedContentId alone still matches two rows for C1, causing the same over-count.
Ironically, the composite PK (userId, feedContentId) is the better design here — it enforces that each user can only mark a given content item as read exactly once (a database-level uniqueness guarantee). A standalone auto-increment PK would actually be weaker, as it would allow accidental duplicate (userId, feedContentId) pairs without extra unique constraints.
The root cause was always purely in the JOIN condition, which is now correctly fixed in the current code with eq(usersFeedsReadContent.userId, user.user.id).
🧠 Learnings used
Learnt from: mtlaso
Repo: mtlaso/bocal PR: 253
File: src/app/[locale]/ui/feeds/sidebar/feeds-sidebar-content.tsx:31-39
Timestamp: 2025-08-09T01:11:03.087Z
Learning: In the bocal application, the `getUserFeedsGroupedByFolder()` function returns a `Map<string | null, FeedFolder>` where the key is either a string (representing the folder name) or null (representing uncategorized feeds). When rendering in `FeedsSidebarContent`, checking `if (key)` correctly distinguishes between named folders (truthy string) and uncategorized feeds (falsy null).
There was a problem hiding this comment.
🧩 Analysis chain
Let me look at the relevant files to understand the architecture before suggesting an approach.
🏁 Script executed:
#!/bin/bash
# Read the layout file
fd "layout.tsx" --full-path | xargs grep -l "feeds" | head -5
# Read the feeds layout
cat src/app/[locale]/\(app\)/d/\(sidebar\)/feeds/layout.tsx 2>/dev/null || \
fd "layout.tsx" | xargs grep -l "feeds" | head -5
# Read feeds-timeline
fd "feeds-timeline.tsx" --exec cat {}
# Read feeds-sidebar-content.tsx
fd "feeds-sidebar-content.tsx" --exec cat {}
# Read feeds-sidebar-item.tsx
fd "feeds-sidebar-item.tsx" --exec cat {}
# Read feeds-sidebar-folder.tsx
fd "feeds-sidebar-folder.tsx" --exec cat {}
Length of output: 20571
@mtlaso, great question. The core challenge is that FeedsSidebar and the <main> area are sibling subtrees with no shared mutable state — only SidebarFeedsProvider (the shadcn sidebar context) wraps both.
The cleanest approach is to introduce a shared React context for unread count deltas, placed in the layout to bridge the two trees.
How it works
sequenceDiagram
participant Timeline as FeedsTimeline (Item)
participant Ctx as UnreadCountProvider (layout)
participant Sidebar as FeedsSidebarItem / Folder
Timeline->>Ctx: updateUnreadDelta(feedId, -1)
Ctx-->>Sidebar: badge = contentsCount - readContentsCount + delta(feedId)
Timeline->>Server: markFeedContentAsRead() + revalidatePath()
Server-->>Sidebar: fresh FeedFolder[] with updated readContentsCount
Timeline->>Ctx: resetDeltas()
Ctx-->>Sidebar: delta back to 0 (server data is now accurate)
Step 1 — Create the context
// src/lib/contexts/unread-count-context.tsx
"use client";
import { createContext, useCallback, useContext, useState } from "react";
type UnreadCountContextType = {
getUnreadDelta: (feedId: number) => number;
updateUnreadDelta: (feedId: number, delta: number) => void;
resetDeltas: () => void;
};
const UnreadCountContext = createContext<UnreadCountContextType | null>(null);
export function UnreadCountProvider({ children }: { children: React.ReactNode }) {
const [deltas, setDeltas] = useState<Map<number, number>>(new Map());
const getUnreadDelta = useCallback(
(feedId: number) => deltas.get(feedId) ?? 0,
[deltas],
);
const updateUnreadDelta = useCallback((feedId: number, delta: number) => {
setDeltas((prev) => {
const next = new Map(prev);
next.set(feedId, (next.get(feedId) ?? 0) + delta);
return next;
});
}, []);
const resetDeltas = useCallback(() => setDeltas(new Map()), []);
return (
<UnreadCountContext.Provider value={{ getUnreadDelta, updateUnreadDelta, resetDeltas }}>
{children}
</UnreadCountContext.Provider>
);
}
export function useUnreadCount() {
const ctx = useContext(UnreadCountContext);
if (!ctx) throw new Error("useUnreadCount must be used within UnreadCountProvider");
return ctx;
}Step 2 — Add the provider to the layout
The layout needs a thin client wrapper (to keep the layout itself as a Server Component):
// src/app/[locale]/(app)/d/(sidebar)/feeds/layout.tsx
import { UnreadCountProvider } from "@/lib/contexts/unread-count-context";
// ...
export default function DashboardLayout({ children }) {
return (
<SidebarFeedsProvider>
<UnreadCountProvider> {/* ← new */}
<FeedsSidebar />
<div className="...">
<AppNavigationMenu />
<main>{children}</main>
</div>
</UnreadCountProvider>
</SidebarFeedsProvider>
);
}Step 3 — Fire deltas from FeedsTimeline
// In Item, inside feeds-timeline.tsx
const { updateUnreadDelta, resetDeltas } = useUnreadCount();
const handleMarkAsRead = async (feedId: number, feedContentId: number) => {
startTransition(async () => {
try {
setIsRead(true);
updateUnreadDelta(feedId, -1); // optimistic sidebar update
const res = await markFeedContentAsRead(feedId, feedContentId);
if (res.errors || res.errI18Key) {
setIsRead(false);
updateUnreadDelta(feedId, +1); // rollback
// ... toast
return;
}
resetDeltas(); // server data is now fresh
} catch {
setIsRead(false);
updateUnreadDelta(feedId, +1);
}
});
};
// Mirror for handleMarkAsUnread (+1 optimistic, -1 rollback)Step 4 — Consume the delta in the sidebar badge
// FeedsSidebarItem & Draggable in feeds-sidebar-folder.tsx
const { getUnreadDelta } = useUnreadCount();
const unread = Math.max(0, feed.contentsCount - feed.readContentsCount + getUnreadDelta(feed.id));
// <SidebarMenuBadge>{unread}</SidebarMenuBadge>And in FeedsSidebarContent for the totalFeedsContents sum:
const totalFeedsContents = userFeedsGroupedByFolder.values().reduce(
(acc, folder) =>
acc +
folder.feeds.reduce((sacc, f) =>
sacc + Math.max(0, f.contentsCount - f.readContentsCount + getUnreadDelta(f.id)), 0),
0,
);Why resetDeltas() after the server action?
markFeedContentAsRead calls revalidatePath server-side. By the time the client's startTransition callback finishes, Next.js has already pushed the updated RSC payload — meaning userFeedsGroupedByFolderPromise resolves with fresh readContentsCount. Calling resetDeltas() at that point ensures the delta doesn't double-subtract from the already-updated server value.
🧠 Learnings used
Learnt from: mtlaso
Repo: mtlaso/bocal PR: 253
File: src/app/[locale]/ui/feeds/sidebar/feeds-sidebar-content.tsx:31-39
Timestamp: 2025-08-09T01:11:03.087Z
Learning: In the bocal application, the `getUserFeedsGroupedByFolder()` function returns a `Map<string | null, FeedFolder>` where the key is either a string (representing the folder name) or null (representing uncategorized feeds). When rendering in `FeedsSidebarContent`, checking `if (key)` correctly distinguishes between named folders (truthy string) and uncategorized feeds (falsy null).
Learnt from: mtlaso
Repo: mtlaso/bocal PR: 327
File: src/app/[locale]/ui/feeds/sidebar/feeds-sidebar-content.tsx:5-5
Timestamp: 2026-03-07T05:44:50.055Z
Learning: In React's `useOptimistic` hook, the optimistic state automatically reverts to the base `value` (first argument) once the async transition completes — whether it succeeds or fails. A manual rollback snapshot (e.g., via `useRef`) is therefore redundant. This applies to the `useOptimistic` usage in `src/app/[locale]/ui/feeds/sidebar/feeds-sidebar-content.tsx` in the bocal project.
There was a problem hiding this comment.
Yes, so we need to have some system to share data between these two components since this is not a completely client-side app, and since these components do not depend on the same state. Do you think it's possible to use something such as nuqs ? It shares data through the url, but this option seems more complex to me, or so? Se these files to get a feeling on the current usage of nuqs in this app:
src/lib/stores/search-params-states.ts
src/app/[locale]/ui/links/links.tsx
src/app/[locale]/ui/links/sort-links.tsx
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
cat src/lib/stores/search-params-states.ts
echo "---"
cat src/app/\[locale\]/ui/links/links.tsx
echo "---"
cat src/app/\[locale\]/ui/links/sort-links.tsx
Length of output: 8485
@mtlaso, great question — looking at your existing nuqs usage confirms the patterns clearly. Here's the comparison:
nuqs: designed for persistent, shareable state
In your codebase, nuqs is used for searchedLink, sortLinks, and selectedFeed — state that makes sense in the URL because it's:
- Bookmarkable (share a filtered/sorted view)
- Refreshable (the same view survives F5)
- Server-relevant (used for server-side filtering)
Unread count deltas don't fit any of these criteria. Applying nuqs here would mean:
- The URL would get littered with params like
?delta_42=-1&delta_7=-2on every read action - Deltas are meaningless and confusing if bookmarked or shared
- The state is purely transient — it only lives for the ~200ms between user action and server revalidation
Even using nuqs's shallow: true mode (no server round-trip) still writes to the URL, which is noise here.
React Context: designed for ephemeral, in-memory shared state
The UnreadCountProvider I outlined earlier fits the problem perfectly:
- It lives in the layout, bridging the sidebar and
<main>trees - It holds no URL state — it disappears naturally on navigation
- It's scoped exactly to the feeds section and doesn't leak elsewhere
- No URL pollution — the address bar stays clean
Rule of thumb for this app
| Use case | Tool |
|---|---|
| Shareable/bookmarkable UI preferences (sort, filter, selected feed) | nuqs |
| Ephemeral optimistic UI deltas (unread counts) | React Context |
So the React Context approach I described previously is the right fit here — nuqs would work mechanically but is the wrong abstraction for transient optimistic state. 🐇
🧠 Learnings used
Learnt from: mtlaso
Repo: mtlaso/bocal PR: 253
File: src/app/[locale]/ui/feeds/sidebar/feeds-sidebar-content.tsx:31-39
Timestamp: 2025-08-09T01:11:03.087Z
Learning: In the bocal application, the `getUserFeedsGroupedByFolder()` function returns a `Map<string | null, FeedFolder>` where the key is either a string (representing the folder name) or null (representing uncategorized feeds). When rendering in `FeedsSidebarContent`, checking `if (key)` correctly distinguishes between named folders (truthy string) and uncategorized feeds (falsy null).
Learnt from: mtlaso
Repo: mtlaso/bocal PR: 327
File: src/app/[locale]/ui/feeds/sidebar/feeds-sidebar-content.tsx:5-5
Timestamp: 2026-03-07T05:44:50.055Z
Learning: In React's `useOptimistic` hook, the optimistic state automatically reverts to the base `value` (first argument) once the async transition completes — whether it succeeds or fails. A manual rollback snapshot (e.g., via `useRef`) is therefore redundant. This applies to the `useOptimistic` usage in `src/app/[locale]/ui/feeds/sidebar/feeds-sidebar-content.tsx` in the bocal project.
There was a problem hiding this comment.
Or something such like zustand?
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Check if zustand is already a dependency
cat package.json | grep -i zustand
Length of output: 0
@mtlaso, Zustand would work mechanically, but since it's not currently in your project, it would be a new dependency — and for this specific use case, it's not worth the trade-off. Here's the full breakdown:
Zustand vs. React Context for this case
| React Context | Zustand | |
|---|---|---|
| New dependency? | ❌ No | ✅ Yes (npm i zustand) |
| Provider in layout needed? | Yes | No (global store) |
| Scoped to feeds layout? | ✅ Naturally (unmounts with layout) | |
| Re-render behaviour | All context consumers re-render | Only subscribed components re-render |
| Devtools | No | Yes |
| Complexity for this delta map | Low | Low |
Why React Context wins here
1. No new dependency. Zustand is a great library, but adding it just to share a transient delta map that lives for ~200ms is overkill.
2. Performance advantage doesn't apply. Zustand's main selling point over Context is avoiding unnecessary re-renders for high-frequency updates. Badge updates happen when a user clicks a feed item — not on every keystroke or scroll. Context is more than fast enough.
3. Lifetime management is automatic. With Context inside the feeds layout, the delta state is automatically destroyed when the user navigates away. With a Zustand global store, you'd need to explicitly reset the state (e.g., in a useEffect cleanup or on navigation events), otherwise stale deltas from a previous visit could bleed into the next render.
When Zustand would make sense
If Zustand were already in your project (e.g., for other global state like auth or theme), reusing it here would be perfectly reasonable. The pattern would look like:
// src/lib/stores/unread-count-store.ts
import { create } from "zustand";
type UnreadCountStore = {
deltas: Map<number, number>;
updateDelta: (feedId: number, delta: number) => void;
resetDeltas: () => void;
};
export const useUnreadCountStore = create<UnreadCountStore>((set) => ({
deltas: new Map(),
updateDelta: (feedId, delta) =>
set((state) => {
const next = new Map(state.deltas);
next.set(feedId, (next.get(feedId) ?? 0) + delta);
return { deltas: next };
}),
resetDeltas: () => set({ deltas: new Map() }),
}));And you'd call resetDeltas() in a useEffect cleanup inside the feeds layout. But since Zustand isn't already here, the React Context approach is cleaner and keeps the dependency tree lean.
🧠 Learnings used
Learnt from: mtlaso
Repo: mtlaso/bocal PR: 253
File: src/app/[locale]/ui/feeds/sidebar/feeds-sidebar-content.tsx:31-39
Timestamp: 2025-08-09T01:11:03.087Z
Learning: In the bocal application, the `getUserFeedsGroupedByFolder()` function returns a `Map<string | null, FeedFolder>` where the key is either a string (representing the folder name) or null (representing uncategorized feeds). When rendering in `FeedsSidebarContent`, checking `if (key)` correctly distinguishes between named folders (truthy string) and uncategorized feeds (falsy null).
Learnt from: mtlaso
Repo: mtlaso/bocal PR: 327
File: src/app/[locale]/ui/feeds/sidebar/feeds-sidebar-content.tsx:5-5
Timestamp: 2026-03-07T05:44:50.055Z
Learning: In React's `useOptimistic` hook, the optimistic state automatically reverts to the base `value` (first argument) once the async transition completes — whether it succeeds or fails. A manual rollback snapshot (e.g., via `useRef`) is therefore redundant. This applies to the `useOptimistic` usage in `src/app/[locale]/ui/feeds/sidebar/feeds-sidebar-content.tsx` in the bocal project.
|
Vercel Preview URL 🚀 : https://bocal-w6mkao8x5-euuuuhs-projects.vercel.app |
|
Vercel Preview URL 🚀 : https://bocal-bz4uf8bz2-euuuuhs-projects.vercel.app |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/app/`[locale]/ui/feeds/sidebar/feeds-sidebar-content.tsx:
- Line 5: The optimistic folder deletion lost its rollback path because the
snapshot removed from useOptimistic was the only way to restore state on
failure; restore the rollback by reintroducing the snapshot logic in the
component that calls useOptimistic (or re-add the previous snapshot variable and
call setRollback/reset on failure), or move the optimistic update so it only
runs after a successful server response from deleteFeedFolder; specifically
adjust handleOnRemove and the DeleteFolder flow (which currently calls
onDelete(id) before awaiting deleteFeedFolder) to either wait for
deleteFeedFolder to succeed before calling onDelete, or capture and store the
useOptimistic snapshot and call its rollback/reset if deleteFeedFolder throws
(and ensure any error returned by the server action triggers revalidation or
rollback).
In `@src/app/`[locale]/ui/feeds/sidebar/feeds-sidebar-item.tsx:
- Around line 60-62: The unread badge is using feed.readContentsCount derived
from FeedWithContentsCount but the DAL's FeedWithContentsCount query currently
joins usersFeedsReadContent only on feedContentId (usersFeedsReadContent), so
reads from other users affect the count; modify the DAL query
(FeedWithContentsCount) to accept the current user's id (e.g., userId) and
include usersFeedsReadContent.userId = :userId in the join/WHERE so the
readContentsCount is scoped to the signed-in user, then update the place that
fetches FeedWithContentsCount to pass the current user's id before leaving the
UI using SidebarMenuBadge with feed.contentsCount - feed.readContentsCount.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 73ae85a2-5a05-4814-826d-fcf477868059
📒 Files selected for processing (2)
src/app/[locale]/ui/feeds/sidebar/feeds-sidebar-content.tsxsrc/app/[locale]/ui/feeds/sidebar/feeds-sidebar-item.tsx
…lisateurs pourraient suivre le mêmes flux, donc avoir les mêmes readContentsCount. The where(eq(usersFeeds.userId, user.user.id)) only filters which feeds the current user is subscribed to — it scopes rows from the usersFeeds table. It has no effect on usersFeedsReadContent. As confirmed by the schema, usersFeedsReadContent has a composite primary key of (userId, feedId, feedContentId), meaning multiple users can have rows for the exact same feedContentId. The leftJoin is performed before the WHERE filter, and it matches solely on feedContentId = feedsContent.id — so all users' read rows for that content are matched. Concrete example: User A and User B both subscribe to Feed X (10 articles). User B has read 7 articles → 7 rows in usersFeedsReadContent with userId = B. When User A runs this query, the leftJoin matches those 7 rows (because feedContentId matches), and count(readAt) returns 7 for User A — even though User A has read 0 articles. The fix needs a userId filter on the join itself. Since and is already imported, it's a small change — and it applies to both getUserFeedsWithContentsCount and getUserFeedsGroupedByFolder:
|
Vercel Preview URL 🚀 : https://bocal-7ywvlwver-euuuuhs-projects.vercel.app |
|
Vercel Preview URL 🚀 : https://bocal-g24edmkeo-euuuuhs-projects.vercel.app |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/app/`[locale]/ui/feeds/feeds-timeline.tsx:
- Around line 121-123: In handleMarkAsUnread the optimistic update uses
feedsReadCount.updateReadCount(feedId, +1) but the catch block incorrectly
reapplies +1 instead of rolling back; change the rollback in the catch path to
feedsReadCount.updateReadCount(feedId, -1) and ensure setIsRead(true) remains
correct so the UI state and unread counter are reverted on error.
In `@src/lib/stores/feeds-read-count-context.tsx`:
- Around line 55-57: The updateReadCount function currently replaces the
per-feed delta with the new value; change it to accumulate deltas instead: in
updateReadCount(feedId, delta) use setReadCount to produce a new Map based on
prev, read the existing delta for feedId (default 0) and set feedId to existing
+ delta so multiple optimistic updates accumulate (refer to updateReadCount and
the setReadCount(new Map(prev).set(...)) call to locate the change).
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: fb356aa8-eb43-419c-834f-8f6e79b9b7a1
📒 Files selected for processing (8)
src/app/[locale]/(app)/d/(sidebar)/feeds/layout.tsxsrc/app/[locale]/ui/feeds/feeds-timeline.tsxsrc/app/[locale]/ui/feeds/sidebar/feeds-sidebar-content.tsxsrc/app/[locale]/ui/feeds/sidebar/feeds-sidebar-folder.tsxsrc/app/[locale]/ui/feeds/sidebar/feeds-sidebar-item.tsxsrc/lib/constants.tssrc/lib/dal.tssrc/lib/stores/feeds-read-count-context.tsx
🚧 Files skipped from review as they are similar to previous changes (2)
- src/app/[locale]/ui/feeds/sidebar/feeds-sidebar-content.tsx
- src/lib/constants.ts
|
Vercel Preview URL 🚀 : https://bocal-5q5eymorz-euuuuhs-projects.vercel.app |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/app/`[locale]/ui/feeds/feeds-timeline.tsx:
- Line 67: The current update pattern uses absolute ±1 and hard-resets the
per-feed delta to 0 (feedsReadCount.updateReadCount and the reset on success),
which causes race conditions when toggles happen concurrently; change the store
to expose an additive updater (e.g., applyReadCountDelta(feedId, delta)) that
uses setReadCount(prev => { const next = new Map(prev); next.set(feedId,
(next.get(feedId) ?? 0) + delta); return next; }) and replace all current calls
that set ±1 or reset to 0 with additive deltas (increment or decrement) and
remove per-request hard resets so in-flight deltas are not overwritten; keep any
eventual reconciliation logic to compute final state from server responses
rather than zeroing the delta in the optimistic path.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: a7cb3fd4-0850-46e3-af84-e6a1cc348f3e
📒 Files selected for processing (1)
src/app/[locale]/ui/feeds/feeds-timeline.tsx
|
Vercel Preview URL 🚀 : https://bocal-l0f68q88m-euuuuhs-projects.vercel.app |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
src/lib/stores/feeds-read-count-context.tsx (1)
67-69: Prefer removing reset entries instead of storing zero values.
getDeltaalready defaults to0, so deleting the key keeps the map compact and avoids retaining no-op entries.♻️ Proposed refactor
const resetDelta = (feedId: feedId) => { - setDelata((prev) => new Map(prev).set(feedId, 0)); + setDelata((prev) => { + const next = new Map(prev); + next.delete(feedId); + return next; + }); };🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/lib/stores/feeds-read-count-context.tsx` around lines 67 - 69, The resetDelta function currently stores a zero by calling setDelata((prev) => new Map(prev).set(feedId, 0)); instead remove the entry to keep the Map compact: inside resetDelta use setDelata((prev) => { const m = new Map(prev); m.delete(feedId); return m; }); and rely on getDelta's default-of-0 behavior; update the implementation of resetDelta (referencing resetDelta and setDelata) to delete the key instead of setting it to 0.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/app/`[locale]/ui/feeds/sidebar/feeds-sidebar-content.tsx:
- Around line 167-181: The totalFeedsContents calculation is only using
server-side fields and misses client optimistic deltas; update the reduce to
include the client delta from the read-count hook (e.g. useFeedsReadCount) when
summing unread counts so totals match per-feed badges. Specifically, call the
hook used elsewhere (feedsReadCount) and add its value inside the inner reduce
(replace sacc + (f.contentsCount - f.readContentsCount) with sacc +
(f.contentsCount - f.readContentsCount + (feedsReadCount?.getReadCount(f.id) ??
0))) or equivalent, and ensure you safely handle when the hook is undefined.
---
Nitpick comments:
In `@src/lib/stores/feeds-read-count-context.tsx`:
- Around line 67-69: The resetDelta function currently stores a zero by calling
setDelata((prev) => new Map(prev).set(feedId, 0)); instead remove the entry to
keep the Map compact: inside resetDelta use setDelata((prev) => { const m = new
Map(prev); m.delete(feedId); return m; }); and rely on getDelta's default-of-0
behavior; update the implementation of resetDelta (referencing resetDelta and
setDelata) to delete the key instead of setting it to 0.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 4567ff3a-46d9-493e-b1d7-f3bef75b4c4d
📒 Files selected for processing (5)
src/app/[locale]/ui/feeds/feeds-timeline.tsxsrc/app/[locale]/ui/feeds/sidebar/feeds-sidebar-content.tsxsrc/app/[locale]/ui/feeds/sidebar/feeds-sidebar-folder.tsxsrc/app/[locale]/ui/feeds/sidebar/feeds-sidebar-item.tsxsrc/lib/stores/feeds-read-count-context.tsx
🚧 Files skipped from review as they are similar to previous changes (3)
- src/app/[locale]/ui/feeds/sidebar/feeds-sidebar-item.tsx
- src/app/[locale]/ui/feeds/feeds-timeline.tsx
- src/app/[locale]/ui/feeds/sidebar/feeds-sidebar-folder.tsx
|
Vercel Preview URL 🚀 : https://bocal-hq7e07evj-euuuuhs-projects.vercel.app |
There was a problem hiding this comment.
🧹 Nitpick comments (1)
src/app/[locale]/ui/feeds/feeds-timeline.tsx (1)
170-170: Consider guardinghandleMarkAsReadwhen item is already read.Clicking the Link always calls
handleMarkAsRead, even if the item is already read. This applies an optimistic delta-1before the (likely idempotent) server call, causing a brief incorrect count untilresetDeltais called on success.If this is intentional (re-affirm read status), consider adding a guard to skip the delta update when
isReadis already true.🔧 Optional guard
<Link className={cn(SPACING.SM, "grow", { "opacity-50": isRead, })} - onClick={(): Promise<void> => handleMarkAsRead(item.feedId, item.id)} + onClick={(): Promise<void> | void => { + if (!isRead) { + return handleMarkAsRead(item.feedId, item.id); + } + }} href={userfeedsfuncs.formatFeedURL(item.url)} target="_blank" >🤖 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 at line 170, The onClick currently always calls handleMarkAsRead(item.feedId, item.id) and applies an optimistic delta even when the item is already read; add a guard to avoid that by checking the item's read state (item.isRead or isRead) and only invoking handleMarkAsRead when false, or alternatively add an early-return in handleMarkAsRead (by checking isRead for the passed feedId/id) to skip applying the optimistic -1 delta and server call; ensure references to resetDelta remain intact for the success path.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Nitpick comments:
In `@src/app/`[locale]/ui/feeds/feeds-timeline.tsx:
- Line 170: The onClick currently always calls handleMarkAsRead(item.feedId,
item.id) and applies an optimistic delta even when the item is already read; add
a guard to avoid that by checking the item's read state (item.isRead or isRead)
and only invoking handleMarkAsRead when false, or alternatively add an
early-return in handleMarkAsRead (by checking isRead for the passed feedId/id)
to skip applying the optimistic -1 delta and server call; ensure references to
resetDelta remain intact for the success path.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: abc3cc14-291a-4f4c-b68d-15589eebd835
📒 Files selected for processing (5)
src/app/[locale]/ui/feeds/feeds-timeline.tsxsrc/app/[locale]/ui/feeds/sidebar/feeds-sidebar-content.tsxsrc/app/[locale]/ui/feeds/sidebar/feeds-sidebar-folder.tsxsrc/app/[locale]/ui/feeds/sidebar/feeds-sidebar-item.tsxsrc/lib/stores/feeds-read-count-context.tsx
🚧 Files skipped from review as they are similar to previous changes (2)
- src/lib/stores/feeds-read-count-context.tsx
- src/app/[locale]/ui/feeds/sidebar/feeds-sidebar-folder.tsx
|
Vercel Preview URL 🚀 : https://bocal-8mqwg6pyr-euuuuhs-projects.vercel.app |
There was a problem hiding this comment.
Actionable comments posted: 2
♻️ Duplicate comments (2)
src/app/[locale]/ui/feeds/feeds-timeline.tsx (1)
111-137:⚠️ Potential issue | 🟠 MajorRollback target is off by one in error paths.
On failure, rollback currently sets optimistic unread to
feedUnreadCounts + 1(mark-as-read path) orfeedUnreadCounts - 1(mark-as-unread path). Since this API stores an absolute optimistic value, rollback should restorefeedUnreadCountsexactly.💡 Proposed fix
- feedsReadCount.setOptimisticUnread( - feedId, - feedUnreadCounts + 1, - feedUnreadCounts, - ); + feedsReadCount.setOptimisticUnread( + feedId, + feedUnreadCounts, + feedUnreadCounts, + ); ... - feedsReadCount.setOptimisticUnread( - feedId, - feedUnreadCounts + 1, - feedUnreadCounts, - ); + feedsReadCount.setOptimisticUnread( + feedId, + feedUnreadCounts, + feedUnreadCounts, + ); ... - feedsReadCount.setOptimisticUnread( - feedId, - feedUnreadCounts + 1, - feedUnreadCounts, - ); + feedsReadCount.setOptimisticUnread( + feedId, + feedUnreadCounts, + feedUnreadCounts, + ); ... - feedsReadCount.setOptimisticUnread( - feedId, - feedUnreadCounts - 1, - feedUnreadCounts, - ); + feedsReadCount.setOptimisticUnread( + feedId, + feedUnreadCounts, + feedUnreadCounts, + ); ... - feedsReadCount.setOptimisticUnread( - feedId, - feedUnreadCounts - 1, - feedUnreadCounts, - ); + feedsReadCount.setOptimisticUnread( + feedId, + feedUnreadCounts, + feedUnreadCounts, + ); ... - feedsReadCount.setOptimisticUnread( - feedId, - feedUnreadCounts - 1, - feedUnreadCounts, - ); + feedsReadCount.setOptimisticUnread( + feedId, + feedUnreadCounts, + feedUnreadCounts, + );Also applies to: 165-191
🤖 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 111 - 137, The rollback in the error paths restores the optimistic unread count incorrectly (using feedUnreadCounts + 1 or -1) even though setOptimisticUnread expects an absolute value; update all error branches that call feedsReadCount.setOptimisticUnread (including the blocks handling res.errors, res.errI18Key, the catch, and the similar block around the 165–191 region) to restore the original value by calling feedsReadCount.setOptimisticUnread(feedId, feedUnreadCounts, feedUnreadCounts) and ensure setIsRead is reverted as appropriate.src/lib/stores/feeds-read-count-context.tsx (1)
34-53:⚠️ Potential issue | 🟠 MajorOptimistic state model is vulnerable to last-write-wins races per feed.
setOptimisticUnreadoverwrites a single entry per feed, andclearOptimisticdeletes it unconditionally. If multiple mark read/unread actions overlap for the same feed, intermediate optimistic updates can be lost when one request writes/clears over another.Also applies to: 56-61
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/lib/stores/feeds-read-count-context.tsx` around lines 34 - 53, The optimistic map currently allows last-write-wins races for a feed because setOptimisticUnread blindly overwrites and clearOptimistic blindly deletes; modify the entry model and update logic so updates are applied atomically and only when they correspond to the same optimistic operation: extend the stored value to include a unique operation id or timestamp (e.g., requestId or opVersion) alongside optimistic and baseline, update setOptimisticUnread to set the entry with that id, and change clearOptimistic (and any other updater) to only remove or update the entry if the stored id/version matches (or baseline matches) the caller's id/version, so concurrent overlapping mark-read/unread calls do not clobber each other; refer to getUnreadCount, setOptimisticUnread, and clearOptimistic when locating where to change the Map handling and comparisons.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/app/`[locale]/ui/feeds/feeds-timeline.tsx:
- Around line 87-89: The artificial 2s delay is caused by the helper wait and
its usage (await wait(2000)) in the mark-as-read mutation path; remove the await
wait(2000) calls so the mutation (e.g., the read/markAsRead mutation invocation)
is executed immediately and delete or keep the wait helper only if used
elsewhere, ensuring both occurrences (the one around the read mutation and the
other at the noted occurrence) are removed or disabled so marking items as read
is not artificially delayed.
In `@src/lib/stores/feeds-read-count-context.tsx`:
- Around line 23-24: Update the thrown guard message to reference the actual
exported hook and provider names: replace the current "useFeedsReadCount must be
used within a FeedsReadCountProvider" with "useFeedsUnereadCount must be used
within a FeedsUnreadCountProvider" (or, if the correct export is
useFeedsUnreadCount, use that exact symbol) so the error matches the exported
hook (useFeedsUnereadCount / useFeedsUnreadCount) and provider
(FeedsUnreadCountProvider) in this module.
---
Duplicate comments:
In `@src/app/`[locale]/ui/feeds/feeds-timeline.tsx:
- Around line 111-137: The rollback in the error paths restores the optimistic
unread count incorrectly (using feedUnreadCounts + 1 or -1) even though
setOptimisticUnread expects an absolute value; update all error branches that
call feedsReadCount.setOptimisticUnread (including the blocks handling
res.errors, res.errI18Key, the catch, and the similar block around the 165–191
region) to restore the original value by calling
feedsReadCount.setOptimisticUnread(feedId, feedUnreadCounts, feedUnreadCounts)
and ensure setIsRead is reverted as appropriate.
In `@src/lib/stores/feeds-read-count-context.tsx`:
- Around line 34-53: The optimistic map currently allows last-write-wins races
for a feed because setOptimisticUnread blindly overwrites and clearOptimistic
blindly deletes; modify the entry model and update logic so updates are applied
atomically and only when they correspond to the same optimistic operation:
extend the stored value to include a unique operation id or timestamp (e.g.,
requestId or opVersion) alongside optimistic and baseline, update
setOptimisticUnread to set the entry with that id, and change clearOptimistic
(and any other updater) to only remove or update the entry if the stored
id/version matches (or baseline matches) the caller's id/version, so concurrent
overlapping mark-read/unread calls do not clobber each other; refer to
getUnreadCount, setOptimisticUnread, and clearOptimistic when locating where to
change the Map handling and comparisons.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: cf8d48b7-8f3a-4b22-9629-276d5657d272
📒 Files selected for processing (8)
.zed/debug.jsonnext.config.tssrc/app/[locale]/(app)/d/(sidebar)/feeds/layout.tsxsrc/app/[locale]/ui/feeds/feeds-timeline.tsxsrc/app/[locale]/ui/feeds/sidebar/feeds-sidebar-content.tsxsrc/app/[locale]/ui/feeds/sidebar/feeds-sidebar-folder.tsxsrc/app/[locale]/ui/feeds/sidebar/feeds-sidebar-item.tsxsrc/lib/stores/feeds-read-count-context.tsx
💤 Files with no reviewable changes (1)
- next.config.ts
✅ Files skipped from review due to trivial changes (1)
- .zed/debug.json
🚧 Files skipped from review as they are similar to previous changes (2)
- src/app/[locale]/(app)/d/(sidebar)/feeds/layout.tsx
- src/app/[locale]/ui/feeds/sidebar/feeds-sidebar-content.tsx
|
Vercel Preview URL 🚀 : https://bocal-ngv1q6s04-euuuuhs-projects.vercel.app |
|
Vercel Preview URL 🚀 : https://bocal-c5bo6z5ez-euuuuhs-projects.vercel.app |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/app/`[locale]/ui/feeds/feeds-timeline.tsx:
- Around line 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.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 4e52493e-c13c-4ab6-b1a9-15b35c8b7598
📒 Files selected for processing (2)
src/app/[locale]/ui/feeds/feeds-timeline.tsxsrc/app/[locale]/ui/feeds/sidebar/feeds-sidebar-content.tsx
| 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); |
There was a problem hiding this comment.
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.
En tant qu’utilisateur
Je veux voir le nombre de contenu non lus dans la barre latérale dans /feeds
Afin que je puisse savoir combien de choses que je n’ai pas lu
Au lieu de ce qui est affiché actuellement : le nombre total de contenu
Summary by CodeRabbit
New Features
Refactor