Skip to content

feat: afficher le nombre de contenu non lus dans la barre latérale dans /feeds#327

Open
mtlaso wants to merge 13 commits intomasterfrom
nb-elements-non-lus-feeds
Open

feat: afficher le nombre de contenu non lus dans la barre latérale dans /feeds#327
mtlaso wants to merge 13 commits intomasterfrom
nb-elements-non-lus-feeds

Conversation

@mtlaso
Copy link
Copy Markdown
Owner

@mtlaso mtlaso commented Mar 7, 2026

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

    • Sidebar badges and totals now reflect server read counts combined with local per-feed deltas.
    • Immediate optimistic mark-as-read/unread with instant badge updates and automatic revert plus user-facing error toasts.
  • Refactor

    • Removed prior snapshot-based rollback logic for optimistic operations.
    • Added a lightweight provider to track per-feed unread deltas and wrapped sidebar/timeline to use it.

@vercel
Copy link
Copy Markdown

vercel bot commented Mar 7, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
bocal Ignored Ignored Preview Mar 13, 2026 7:11am

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 7, 2026

Warning

Rate limit exceeded

@mtlaso has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 10 minutes and 38 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a31c93fe-0c7f-407c-a31c-41845e285416

📥 Commits

Reviewing files that changed from the base of the PR and between 430a7cd and 80b861e.

📒 Files selected for processing (1)
  • src/lib/stores/feeds-read-count-context.tsx
📝 Walkthrough

Walkthrough

Adds server-side per-feed read counts and a client-side FeedsUnreadCountProvider for optimistic per-feed unread deltas; updates DAL/type to include readContentsCount, wires provider into layout, updates sidebar badges/totals and timeline to use optimistic unread logic, and removes prior rollback-snapshot code.

Changes

Cohort / File(s) Summary
DAL & Types
src/lib/dal.ts, src/lib/constants.ts
Add readContentsCount to FeedWithContentsCount; queries LEFT JOIN users_feeds_read_content and select aggregated readContentsCount; folder feed objects include readContentsCount.
Unread-count Store / Provider
src/lib/stores/feeds-read-count-context.tsx
New FeedsUnreadCountProvider and useFeedsUnereadCount hook exposing getUnreadCount, setOptimisticUnread, and clearOptimistic for per-feed optimistic deltas.
App Layout Wiring
src/app/[locale]/(app)/d/(sidebar)/feeds/layout.tsx
Wraps sidebar UI and content with FeedsUnreadCountProvider (nested inside existing SidebarFeedsProvider).
Sidebar UI (badges & totals)
src/app/[locale]/ui/feeds/sidebar/feeds-sidebar-item.tsx, src/app/[locale]/ui/feeds/sidebar/feeds-sidebar-folder.tsx, src/app/[locale]/ui/feeds/sidebar/feeds-sidebar-content.tsx
Replace direct counts with provider-aware unread calculation using contentsCount - readContentsCount as serverUnread and getUnreadCount(...); remove rollbackSnapshotRef logic.
Timeline — optimistic read/unread
src/app/[locale]/ui/feeds/feeds-timeline.tsx
Compute initial per-feed unread map, thread counts into items, perform optimistic updates via provider on mark-as-read/unread flows, call API, and revert optimistic state on error.
Misc / Config / Debug
.zed/debug.json, next.config.ts
Add local debug template file; remove cacheComponents: true flag from Next config.

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)
Loading
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
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐇 I nibble counts and hop with tiny glee,

I stash optimistic deltas for feeds I see,
Badges shrink, then server says OK or sighs,
If errors bite, I hop back — no surprise.
Carrots, deltas, and a hopeful rabbit cheer.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 14.29% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: displaying unread content counts in the /feeds sidebar instead of total counts.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch nb-elements-non-lus-feeds
📝 Coding Plan
  • Generate coding plan for human review comments

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions
Copy link
Copy Markdown

github-actions bot commented Mar 7, 2026

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

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

📥 Commits

Reviewing files that changed from the base of the PR and between f2f59e4 and 32caa6a.

📒 Files selected for processing (4)
  • src/app/[locale]/ui/feeds/sidebar/feeds-sidebar-content.tsx
  • src/app/[locale]/ui/feeds/sidebar/feeds-sidebar-folder.tsx
  • src/lib/constants.ts
  • src/lib/dal.ts

Comment on lines +231 to +234
.leftJoin(
usersFeedsReadContent,
eq(usersFeedsReadContent.feedContentId, feedsContent.id),
)
Copy link
Copy Markdown

@coderabbitai coderabbitai bot Mar 7, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

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.

Suggested change
.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

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

what about the where clause? it does that right?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@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) in usersFeedsReadContent.
  • User B also read C1 → row (userId=B, feedContentId=C1) in usersFeedsReadContent.

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).

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Okay, I guess this wouldn't be a problem if usersFeedsReadContent had it's own primary key (not composite)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧩 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).

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧩 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)
Loading

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.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧩 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=-2 on 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.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Or something such like zustand?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧩 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) ⚠️ Needs explicit reset on navigation
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.

@github-actions
Copy link
Copy Markdown

github-actions bot commented Mar 7, 2026

@github-actions
Copy link
Copy Markdown

github-actions bot commented Mar 7, 2026

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

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

📥 Commits

Reviewing files that changed from the base of the PR and between 32caa6a and d5f68fb.

📒 Files selected for processing (2)
  • src/app/[locale]/ui/feeds/sidebar/feeds-sidebar-content.tsx
  • src/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:
@github-actions
Copy link
Copy Markdown

github-actions bot commented Mar 7, 2026

@github-actions
Copy link
Copy Markdown

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

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

📥 Commits

Reviewing files that changed from the base of the PR and between d5f68fb and 13859fd.

📒 Files selected for processing (8)
  • src/app/[locale]/(app)/d/(sidebar)/feeds/layout.tsx
  • src/app/[locale]/ui/feeds/feeds-timeline.tsx
  • src/app/[locale]/ui/feeds/sidebar/feeds-sidebar-content.tsx
  • src/app/[locale]/ui/feeds/sidebar/feeds-sidebar-folder.tsx
  • src/app/[locale]/ui/feeds/sidebar/feeds-sidebar-item.tsx
  • src/lib/constants.ts
  • src/lib/dal.ts
  • src/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

@github-actions
Copy link
Copy Markdown

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

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

📥 Commits

Reviewing files that changed from the base of the PR and between 13859fd and 2c65bd8.

📒 Files selected for processing (1)
  • src/app/[locale]/ui/feeds/feeds-timeline.tsx

@github-actions
Copy link
Copy Markdown

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

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.

getDelta already defaults to 0, 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

📥 Commits

Reviewing files that changed from the base of the PR and between 2c65bd8 and 523b09d.

📒 Files selected for processing (5)
  • src/app/[locale]/ui/feeds/feeds-timeline.tsx
  • src/app/[locale]/ui/feeds/sidebar/feeds-sidebar-content.tsx
  • src/app/[locale]/ui/feeds/sidebar/feeds-sidebar-folder.tsx
  • src/app/[locale]/ui/feeds/sidebar/feeds-sidebar-item.tsx
  • src/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

@github-actions
Copy link
Copy Markdown

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
src/app/[locale]/ui/feeds/feeds-timeline.tsx (1)

170-170: Consider guarding handleMarkAsRead when item is already read.

Clicking the Link always calls handleMarkAsRead, even if the item is already read. This applies an optimistic delta -1 before the (likely idempotent) server call, causing a brief incorrect count until resetDelta is called on success.

If this is intentional (re-affirm read status), consider adding a guard to skip the delta update when isRead is 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

📥 Commits

Reviewing files that changed from the base of the PR and between 2c65bd8 and 5b54c94.

📒 Files selected for processing (5)
  • src/app/[locale]/ui/feeds/feeds-timeline.tsx
  • src/app/[locale]/ui/feeds/sidebar/feeds-sidebar-content.tsx
  • src/app/[locale]/ui/feeds/sidebar/feeds-sidebar-folder.tsx
  • src/app/[locale]/ui/feeds/sidebar/feeds-sidebar-item.tsx
  • src/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

@github-actions
Copy link
Copy Markdown

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

♻️ Duplicate comments (2)
src/app/[locale]/ui/feeds/feeds-timeline.tsx (1)

111-137: ⚠️ Potential issue | 🟠 Major

Rollback target is off by one in error paths.

On failure, rollback currently sets optimistic unread to feedUnreadCounts + 1 (mark-as-read path) or feedUnreadCounts - 1 (mark-as-unread path). Since this API stores an absolute optimistic value, rollback should restore feedUnreadCounts exactly.

💡 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 | 🟠 Major

Optimistic state model is vulnerable to last-write-wins races per feed.

setOptimisticUnread overwrites a single entry per feed, and clearOptimistic deletes 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

📥 Commits

Reviewing files that changed from the base of the PR and between 5b54c94 and 08064d0.

📒 Files selected for processing (8)
  • .zed/debug.json
  • next.config.ts
  • src/app/[locale]/(app)/d/(sidebar)/feeds/layout.tsx
  • src/app/[locale]/ui/feeds/feeds-timeline.tsx
  • src/app/[locale]/ui/feeds/sidebar/feeds-sidebar-content.tsx
  • src/app/[locale]/ui/feeds/sidebar/feeds-sidebar-folder.tsx
  • src/app/[locale]/ui/feeds/sidebar/feeds-sidebar-item.tsx
  • src/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

@github-actions
Copy link
Copy Markdown

@github-actions
Copy link
Copy Markdown

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

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

📥 Commits

Reviewing files that changed from the base of the PR and between 08064d0 and 430a7cd.

📒 Files selected for processing (2)
  • src/app/[locale]/ui/feeds/feeds-timeline.tsx
  • src/app/[locale]/ui/feeds/sidebar/feeds-sidebar-content.tsx

Comment on lines 84 to 100
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);
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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant