Skip to content

feat(bookmarks): production harden saved bounty system - #188

Merged
Benjtalkshow merged 7 commits into
boundlessfi:mainfrom
Shadow-MMN:feat/bookmark-system-production-hardening
Apr 25, 2026
Merged

feat(bookmarks): production harden saved bounty system#188
Benjtalkshow merged 7 commits into
boundlessfi:mainfrom
Shadow-MMN:feat/bookmark-system-production-hardening

Conversation

@Shadow-MMN

@Shadow-MMN Shadow-MMN commented Apr 25, 2026

Copy link
Copy Markdown
Contributor

Production Hardening: Saved / Bookmarked Bounties

This PR finalizes the bookmark system and brings it to production-grade reliability, performance, and consistency.


What Changed

1. Persistence Layer

  • Replaced all in-memory bookmark storage with GraphQL-backed APIs
  • Ensures bookmarks persist across sessions and devices

2. Performance Optimization

  • Introduced bookmarkKeys.ids() cache for O(1) lookup
  • Replaced all .includes() and .some() scans with Set-based checks
  • Eliminates linear search across UI and notification paths

3. Cache Architecture Improvements

  • Split bookmark state into:
    • ids() → source of truth for membership
    • list() → derived full bookmark objects
  • Ensures clear separation of concerns and avoids UI desync

4. Optimistic Update Stability

  • Prevented undefined cache states using safe defaults (?? [])
  • Removed all fake/placeholder bounty objects
  • Ensured list cache only updates when real data exists
  • Atomic rollback for both caches on mutation failure

5. Notification System Fixes

  • Notifications now rely only on ids() cache for membership checks
  • Added meaningful-change detection (status transitions only)
  • Introduced unique notification IDs to prevent collisions

6. API Improvements

  • Added /api/bookmarks/ids endpoint for lightweight state checks
  • Fully scoped to authenticated user
  • All endpoints now GraphQL-backed

7. UX Improvements

  • Removed inconsistent disabled button states
  • Unauthenticated users receive toast feedback instead
  • Tooltip preserved as guidance only

8. Code Cleanup

  • Removed legacy BookmarkService (in-memory implementation)
  • Eliminated fake optimistic UI data patterns
  • Cleaned up redundant imports and types

Verification Summary

  • O(1) bookmark checks across UI and notifications
  • No stale or inconsistent cache states
  • Fully atomic optimistic updates
  • Production-safe notification deduplication
  • Clean authentication UX flow
  • Fully GraphQL-backed persistence layer

Result

The bookmark system is now:

  • performant
  • consistent
  • production-safe
  • scalable beyond current dataset sizes

No further action required.

Closes #185

Summary by CodeRabbit

  • New Features

    • Save/bookmark bounties with a visible bookmark button on cards and detail pages.
    • "Saved" page to view saved bounties with loading skeletons, empty state, retry on error, and navigation.
    • Server endpoints and client hooks for listing, toggling, and retrieving bookmarked IDs; optimistic UI updates and toasts.
    • Notifications for bookmarked bounties when their status changes.
  • Refactor

    • GraphQL/codegen and client request plumbing improved for stronger typing and document handling; new query-key utilities added.

- Replace in-memory bookmark storage with GraphQL-backed persistence
- Introduce O(1) Set-based bookmark lookups for UI and notifications
- Split bookmark state into ids() and list() caches for consistency
- Fix optimistic updates to avoid undefined state and fake bounty objects
- Ensure atomic rollback across all bookmark-related caches
- Improve notification logic with meaningful-change filtering and unique IDs
- Fix authentication UX by removing inconsistent disabled states
- Add /bookmarks/ids endpoint for optimized state checks
- Remove legacy BookmarkService and unused in-memory logic
@vercel

vercel Bot commented Apr 25, 2026

Copy link
Copy Markdown

Someone is attempting to deploy a commit to the Threadflow Team on Vercel.

A member of the Team first needs to authorize it.

@drips-wave

drips-wave Bot commented Apr 25, 2026

Copy link
Copy Markdown

@Shadow-MMN Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@coderabbitai

coderabbitai Bot commented Apr 25, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Adds server-backed bookmark functionality: GraphQL schema and operations, codegen updates, server API endpoints, React Query hooks with optimistic updates, UI components (bookmark button, saved page), and notification wiring for bookmarked bounty status changes.

Changes

Cohort / File(s) Summary
API endpoints
app/api/bookmarks/[bountyId]/route.ts, app/api/bookmarks/ids/route.ts, app/api/bookmarks/route.ts
New POST toggle and GET list/ids endpoints that authenticate users, proxy GraphQL operations via a server helper, and return JSON with error handling.
Saved page & client
app/saved/page.tsx, app/saved/saved-client.tsx
New server-protected /saved page and client component rendering bookmarked bounties, including loading skeletons, empty/error states, and navigation handlers.
Bookmark UI & integrations
components/bounty/bookmark-button.tsx, components/bounty/bounty-card.tsx, components/bounty-detail/bounty-detail-header-card.tsx
New BookmarkButton client component; added absolute-positioned overlay to bounty card and detail header; interaction handlers stop propagation and ensure accessibility.
React Query hooks & keys
hooks/use-bookmarks.ts, lib/query/query-keys.ts
New hooks: useBookmarks, useBookmarkIds, useToggleBookmark with optimistic updates, rollback on error, and new bookmarkKeys for list/ids/statusCache.
Notifications & subscription
hooks/use-notifications.ts
Introduces "saved-bounty-updated" notification type; subscription logic now consults bookmark caches, emits per-bounty saved-status notifications, and writes status cache to reduce duplicates.
GraphQL schema, ops & codegen
lib/graphql/schema.graphql, lib/graphql/operations/bookmark-operations.graphql, lib/graphql/generated.ts, codegen.ts
Schema adds Bookmark, ToggleBookmarkInput, bookmarks query and toggleBookmark mutation; codegen now emits TypedDocumentString and imports graphql-tag; generated docs include BookmarksDocument and ToggleBookmarkDocument.
GraphQL client helpers (server & client)
lib/graphql/client.ts, lib/server-graphql.ts
Client fetcher now accepts typed documents and builds typed request options; added graphqlRequest server helper supporting typed documents and Bearer auth header handling.
Minor
components/ui/stellar-link.tsx, package.json
Narrowed useMemo deps in StellarLink; added @graphql-typed-document-node/core devDependency.

Sequence Diagram

sequenceDiagram
    participant User as User (browser)
    participant UI as BookmarkButton (client)
    participant Cache as React Query Cache
    participant API as /api/bookmarks/[bountyId]
    participant GraphQL as GraphQL Server

    User->>UI: Click bookmark
    UI->>Cache: cancel queries & snapshot caches
    UI->>Cache: optimistic update ids & list caches
    UI->>UI: show loading state
    UI->>API: POST /api/bookmarks/{bountyId}
    API->>GraphQL: ToggleBookmark mutation via graphqlRequest
    GraphQL-->>API: return Bookmark | null
    API-->>UI: response (200/500)
    alt success
        UI->>Cache: invalidate/read latest ids
        UI->>UI: show final state & toast
    else error
        UI->>Cache: rollback snapshots
        UI->>UI: show error toast
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested reviewers

  • Benjtalkshow
  • 0xdevcollins

"🐰 I hopped through code with a twitch and a twitch,
Saved bounties in burrows, one tasty switch.
Toggle with a thump, a toast and a cheer,
Bookmarks now nest where adventures appear.
🥕✨"

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 60.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Out of Scope Changes check ❓ Inconclusive All changes directly support bookmark/saved bounty functionality or its infrastructure (GraphQL codegen, types, server helpers). The stellar-link tooltip dependency optimization is minimally invasive and unrelated to bookmarks. Confirm that the stellar-link.tsx change (removing 'value' from useMemo deps) is intentional and unrelated to bookmarks, or document its relationship to bookmark system stability.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat(bookmarks): production harden saved bounty system' clearly describes the main focus: production-hardening the bookmarks/saved bounties feature.
Linked Issues check ✅ Passed All key requirements from issue #185 are met: bookmark toggle on cards/detail pages with ARIA attributes, server-backed persistence via GraphQL APIs, /saved route with unauthenticated redirect, notifications for saved bounty status changes, and O(1) Set-based membership checks.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 7

🧹 Nitpick comments (15)
components/ui/stellar-link.tsx (1)

135-135: Optimization is correct, but appears unrelated to bookmark PR.

Removing value from the dependency array is correct because value is not used in the tooltipText computation. The tooltip text only depends on isValid, type, network, explorer, and tooltipPrefix. Since isValid already depends on value (line 76), changes to value that affect validity will still trigger tooltip recomputation through the isValid dependency.

This optimization avoids unnecessary recomputation when value changes between two valid inputs of the same type, improving performance.

However, this change doesn't appear related to the bookmark system (the stated focus of this PR). Consider keeping unrelated optimizations in separate PRs for cleaner git history and easier review.

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

In `@components/ui/stellar-link.tsx` at line 135, The change removing value from
the dependency array of the tooltipText computation is correct (tooltipText only
depends on isValid, type, network, explorer, tooltipPrefix), but it’s unrelated
to the bookmark work in this PR—either move this optimization into its own small
PR or revert it here and add it in a follow-up; reference the tooltipText
computation and the dependency array that now reads [type, network, explorer,
isValid, tooltipPrefix] in components/ui/stellar-link.tsx and ensure isValid
continues to reflect value changes so tooltip updates remain correct.
app/api/bookmarks/ids/route.ts (1)

19-32: Reuse the generated typed document instead of an inline query string.

A typed BookmarksDocument already exists in lib/graphql/generated.ts and a dedicated Bookmarks operation lives in lib/graphql/operations/bookmark-operations.graphql. Hand-rolling a new GetBookmarkIds string here:

  • Bypasses codegen's type safety (the response is any-cast via the explicit generic).
  • Creates a second source of truth that won't be updated when the schema changes.

Consider either:

  1. Adding a small BookmarkIds operation to bookmark-operations.graphql selecting only bountyId, then importing the generated document; or
  2. Reusing BookmarksDocument here (over-fetches, but is type-safe and consistent with app/api/bookmarks/route.ts).

Same applies to app/api/bookmarks/route.ts (see related comment).

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

In `@app/api/bookmarks/ids/route.ts` around lines 19 - 32, Replace the inline
BOOKMARKS_QUERY and manual typing with the generated document: import and use
the existing BookmarksDocument (or add a new BookmarkIds operation in
bookmark-operations.graphql and use its generated document) when calling
graphqlRequest so you get codegen types instead of the explicit generic; update
the call site that currently references BOOKMARKS_QUERY to pass the generated
document (e.g., BookmarksDocument or BookmarkIdsDocument) and then map
data.bookmarks to extract bountyId as before.
app/saved/page.tsx (1)

8-10: Optional: preserve return path on auth redirect.

Consider passing the original path so the user lands back on /saved after signing in, e.g. redirect("/auth?redirect=/saved") (matching whatever query param /auth already supports). Not blocking — current behavior satisfies the acceptance criteria.

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

In `@app/saved/page.tsx` around lines 8 - 10, Update the redirect inside the
authentication check so the original path is preserved and the user returns to
/saved after signing in: in the if (!user) { redirect("/auth"); } block, change
the redirect to include a redirect query parameter containing the current
request path (or '/saved' if you prefer) and ensure the path is URL-encoded; use
the same symbol names shown (the if (!user) guard and redirect(...) call) so the
auth page can read the query (e.g. redirect=?redirect=...) and navigate back
after successful signin.
lib/graphql/client.ts (2)

50-55: normalizeDocument works, but loses the typed-document metadata.

TypedDocumentString carries __meta__ and the parameterized result/variable types, both of which are dropped by query.toString(). For the runtime path this is fine (graphql-request only needs the query string), but anything downstream that introspects the document (e.g., persisted-query plugins, query whitelisting) will see only the raw string. If you ever add such middleware, you’ll need to pass the typed document through unchanged when possible.

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

In `@lib/graphql/client.ts` around lines 50 - 55, normalizeDocument currently
converts non-string GraphQL documents to a string via query.toString(), which
strips TypedDocumentString metadata like __meta__ and typed parameter/result
info; update normalizeDocument to return the original query object unchanged
when it is a TypedDocumentString (or otherwise carries __meta__) so downstream
middleware can access its metadata, i.e., detect the TypedDocumentString shape
(presence of __meta__ or instanceof TypedDocumentString) and return query
directly instead of query.toString(), while still falling back to toString() for
other non-string document types.

75-83: Double-cast as unknown as RequestOptions<TVariables, TData> defeats the type system here.

The as unknown as ... escape hatch suppresses any future API drift in graphql-request (e.g., variables required when the document has non-optional vars). Given this fetcher fans out to every codegen hook in the app, it's worth a tighter, narrowly-typed wrapper rather than a blanket cast. If graphql-request's overloads make a clean signature impossible, please at least leave a comment pointing at the exact constraint being worked around so future maintainers don't widen the hole further.

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

In `@lib/graphql/client.ts` around lines 75 - 83, The double-cast on
requestOptions should be removed and replaced with a narrower, explicit typing
and a short comment documenting the graphql-request overload constraint being
worked around: construct the object into a variable named requestOptions and
either declare it with the exact type RequestOptions<TVariables, TData> (or use
a Partial-to-complete pattern) so the compiler enforces shape for
document/variables/requestHeaders, and only resort to a single, justified cast
if an overload prevents a clean assignment; add a one-line comment referencing
the graphql-request overload issue (e.g., "workaround for graphql-request
RequestOptions overloads requiring different shapes for documents with required
variables") so future maintainers don't widen the type hole, and keep
graphQLClient.request<TData, TVariables>(requestOptions) as the call site.
app/saved/saved-client.tsx (3)

64-72: window.location.href triggers a full page reload — switch to Next.js router for SPA navigation.

Using window.location.href here forces a hard navigation, throwing away the React Query cache, scroll position, layouts, and the BountyCard interaction state. The rest of this app is App Router-based, so navigation should go through useRouter().push() (or wrap the card in a <Link>).

🛠️ Proposed change
+import { useRouter } from "next/navigation";
@@
 function SavedBountiesClient() {
+  const router = useRouter();
   const { data: bookmarks, isLoading, error } = useBookmarks();
@@
       {bookmarkedBounties.map((bounty) => (
         <BountyCard
           key={bounty.id}
           bounty={bounty}
           onClick={() => {
-            window.location.href = `/bounty/${bounty.id}`;
+            router.push(`/bounty/${bounty.id}`);
           }}
         />
       ))}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/saved/saved-client.tsx` around lines 64 - 72, Replace the hard navigation
using window.location.href in saved-client.tsx with Next.js SPA navigation:
import and call useRouter from 'next/navigation' (const router = useRouter())
and replace the onClick handler on BountyCard to
router.push(`/bounty/${bounty.id}`); alternatively wrap the BountyCard with
next/link to the same path. Ensure the component is a client component (has "use
client") if using useRouter from next/navigation, remove window.location.href
usage, and keep the BountyCard's key and props unchanged.

29-38: Retry should refetch the query, not reload the page.

window.location.reload() re-runs the entire app shell just to recover from a transient bookmark-fetch error. useBookmarks() exposes refetch for exactly this case, which preserves the rest of the app state.

🛠️ Proposed change
-  const { data: bookmarks, isLoading, error } = useBookmarks();
+  const { data: bookmarks, isLoading, error, refetch } = useBookmarks();
@@
-        <Button onClick={() => window.location.reload()}>Retry</Button>
+        <Button onClick={() => refetch()}>Retry</Button>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/saved/saved-client.tsx` around lines 29 - 38, The Retry button currently
calls window.location.reload() which refreshes the whole app; change its onClick
to call the refetch function returned by useBookmarks instead so only the
bookmarks query is retried. Locate the component using useBookmarks (the hook
and its refetch symbol) in saved-client.tsx and update the Button onClick from
window.location.reload() to invoke refetch (e.g., onClick={() => refetch()}),
and ensure refetch is defined/returned from the useBookmarks call in the same
scope.

41-43: Type predicate is a no-op; the cast still loses safety.

(b): b is BookmarkType doesn't narrow anything because b is already typed as BookmarkType and BookmarkType.bounty is non-nullable per the schema (Bookmark.bounty: Bounty!). If the API ever can return entries with bounty: null, the underlying type needs to change; otherwise the filter + as BountyType cast adds noise without correctness benefit. Consider simplifying to (bookmarks ?? []).map((b) => b.bounty).

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

In `@app/saved/saved-client.tsx` around lines 41 - 43, The filter uses a no-op
type predicate and then force-casts to BountyType, losing type safety; update
the bookmarkedBounties computation to either map directly to b.bounty when
BookmarkType.bounty is non-nullable (replace the filter+cast with (bookmarks ??
[]).map(b => b.bounty)), or if the API can actually return null bounties make
the BookmarkType.bounty nullable and use a real runtime guard (e.g., filter((b):
b is BookmarkType & { bounty: BountyType } => b.bounty != null).map(b =>
b.bounty)) so you avoid the unsafe as BountyType cast while keeping
bookmarkedBounties correctly typed.
components/bounty/bookmark-button.tsx (2)

80-82: Suspicious cast: widens buttonSize to variants it can never produce.

buttonSize is derived from size: "sm" | "md" | "lg" | "icon" and can only be "default" | "sm" | "lg" | "icon". The cast adds "icon-sm" | "icon-lg", which are unreachable here, masking any real type drift on the Button size prop. If the underlying Button's size prop already includes "default" | "sm" | "lg" | "icon", drop the cast entirely.

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

In `@components/bounty/bookmark-button.tsx` around lines 80 - 82, The prop cast on
size is widening buttonSize incorrectly; update the usage so the Button size
prop receives the correct narrowed type instead of the current cast. Replace the
explicit cast on buttonSize with either no cast (if Button.size already accepts
"default" | "sm" | "lg" | "icon") or map/convert the component's size union
("sm" | "md" | "lg" | "icon") to the exact Button size union, ensuring symbols
referenced are the local variable buttonSize and the Button component's size
prop so unreachable variants "icon-sm" and "icon-lg" are not introduced.

89-91: Loading state regresses the icon affordance.

While the mutation is pending, the button collapses from the bookmark icon to a literal ..., which (a) removes the visual anchor users were just looking at, and (b) breaks layout consistency with the rest of the icon-buttons in this app that use Loader2 from lucide-react. Consider keeping the bookmark glyph and reducing opacity, or swapping in <Loader2 className="animate-spin" />.

🛠️ Proposed change
-import { Bookmark, BookmarkCheck } from "lucide-react";
+import { Bookmark, BookmarkCheck, Loader2 } from "lucide-react";
@@
-      {isLoading ? (
-        <span className="animate-pulse">...</span>
-      ) : isBookmarked ? (
+      {isLoading ? (
+        <Loader2
+          className="animate-spin"
+          style={{ width: iconSize, height: iconSize }}
+        />
+      ) : isBookmarked ? (
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@components/bounty/bookmark-button.tsx` around lines 89 - 91, The loading
state currently replaces the bookmark icon with literal "..." causing layout and
affordance regressions; update the BookmarkButton component so when isLoading is
true you keep the bookmark glyph visible (the same markup used for the
isBookmarked/isNotBookmarked branches) and either reduce its opacity or overlay
a Loader2 spinner with className="animate-spin" instead of showing "..."; locate
uses of isLoading and isBookmarked inside the BookmarkButton render and replace
the conditional that renders "<span className=\"animate-pulse\">...</span>" so
it renders the bookmark icon + a Loader2 spinner (or the icon with reduced
opacity) to preserve layout and accessibility.
app/api/bookmarks/[bountyId]/route.ts (1)

33-85: Prefer the codegen ToggleBookmarkDocument over an inline duplicated mutation string.

The PR already migrates client-side flows to use codegen typed documents (lib/graphql/client.ts now accepts TypedDocumentString). Hard-coding the mutation string here duplicates the operation definition and skips the typed-document benefits, so any future schema field rename (e.g., bountyWindow, _count) silently rots in this file alone.

🛠️ Sketch
-import type { Bookmark } from "@/lib/graphql/generated";
+import {
+  ToggleBookmarkDocument,
+  type ToggleBookmarkMutation,
+  type ToggleBookmarkMutationVariables,
+} from "@/lib/graphql/generated";

-    const TOGGLE_BOOKMARK_MUTATION = `...inline...`;
-    const data = await graphqlRequest<{ toggleBookmark: Bookmark | null }>(
-      TOGGLE_BOOKMARK_MUTATION,
-      { input: { bountyId } },
-    );
+    const data = await graphqlRequest<ToggleBookmarkMutation>(
+      ToggleBookmarkDocument.toString(),
+      { input: { bountyId } } satisfies ToggleBookmarkMutationVariables,
+    );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/api/bookmarks/`[bountyId]/route.ts around lines 33 - 85, Replace the
inline TOGGLE_BOOKMARK_MUTATION string with the generated TypedDocument
ToggleBookmarkDocument and call graphqlRequest with that document (and the same
variables) so this route uses the codegen-typed operation; specifically, remove
TOGGLE_BOOKMARK_MUTATION, import ToggleBookmarkDocument, and pass
ToggleBookmarkDocument to graphqlRequest (preserving the { input: { bountyId } }
payload and the expected response type) so fields like bountyWindow and _count
remain synced with the schema.
lib/server-graphql.ts (2)

21-31: Add a request timeout to prevent hanging route handlers.

fetch here has no timeout, so a slow or unresponsive GraphQL backend will keep the Next.js route handler (and its serverless function / connection) blocked until the platform-level timeout. Consider passing signal: AbortSignal.timeout(10_000) (or similar) so the bookmark API fails fast and returns a 5xx promptly.

🛠️ Proposed change
   const response = await fetch(GRAPHQL_URL, {
     method: "POST",
     headers,
     body: JSON.stringify({ query, variables }),
     // Don't cache - always get fresh data
     cache: "no-store",
+    signal: AbortSignal.timeout(10_000),
   });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/server-graphql.ts` around lines 21 - 31, The fetch call that posts to
GRAPHQL_URL in the GraphQL request (where you pass method, headers, body, cache)
has no timeout and can hang; add a timeout AbortSignal to the fetch options
(e.g., signal: AbortSignal.timeout(10_000)) so the request fails fast, and
handle the resulting abort/timeout error path before the existing response.ok
check to surface a 5xx or appropriate error from the bookmark API.

29-38: Lossy error propagation hides backend status (e.g., 401) and trailing GraphQL errors.

Two minor issues that combine to make production debugging harder:

  1. Line 30 throws only the HTTP status; the response body is dropped, so the upstream GraphQL message is gone by the time it reaches app/api/bookmarks/[bountyId]/route.ts.
  2. Line 35 throws only json.errors[0]?.message and never surfaces extensions.code / status, so the route handler always responds with 500, even when the underlying GraphQL error was 401/403.

Consider attaching status/extensions to a custom error class so callers can map back to the right HTTP code.

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

In `@lib/server-graphql.ts` around lines 29 - 38, The current GraphQL response
handling in lib/server-graphql.ts loses important error details: when
response.ok is false you only throw response.status and drop the response body,
and when json.errors exists you only throw json.errors[0]?.message ignoring
extensions.code/status. Fix this by creating/using a custom error type (e.g.,
GraphQLErrorWithStatus) and populate it with the full response body and HTTP
status when response.ok is false, and when json.errors exists include the first
error's message plus its extensions.code (or map extensions.code to an HTTP
status) and attach both the GraphQL errors array and a status field before
throwing; update the throw sites in the response handling block to throw that
enriched error so callers (like the route handler) can map to the correct HTTP
code.
hooks/use-bookmarks.ts (2)

112-119: Prefer the server result over re-reading the optimistic cache for the success toast.

onSuccess infers the new state by re-reading bookmarkKeys.ids() — which contains the optimistic value, not the authoritative server response. If the server diverges from the optimistic guess (e.g., the bounty was already bookmarked from another device, so toggle removed it), the toast text will be wrong. The mutation already returns Bookmark | null from the server; using _result is more accurate and avoids the redundant as string cast.

Proposed fix
-    onSuccess: (_result, variables) => {
-      // Source of truth: check current IDs cache using Set for O(1)
-      const currentIds =
-        queryClient.getQueryData<string[]>(bookmarkKeys.ids()) ?? [];
-      const currentIdsSet = new Set(currentIds);
-      const isBookmarked = currentIdsSet.has(variables as string);
-      toast.success(isBookmarked ? "Bounty bookmarked!" : "Bookmark removed");
-    },
+    onSuccess: (result) => {
+      toast.success(result ? "Bounty bookmarked!" : "Bookmark removed");
+    },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@hooks/use-bookmarks.ts` around lines 112 - 119, onSuccess currently re-reads
the optimistic cache (bookmarkKeys.ids()) and casts variables to string to
decide the toast; instead use the server response (_result) returned by the
mutation to determine whether the bookmark exists and show the correct toast.
Update the onSuccess handler in useBookmarks (the mutation's onSuccess callback)
to inspect _result (which is Bookmark | null) and call toast.success with
"Bounty bookmarked!" when _result is non-null or "Bookmark removed" when null,
remove the Set/check against
queryClient.getQueryData<string[]>(bookmarkKeys.ids()) and avoid the redundant
"as string" cast.

99-105: err.message access is unsafe without typing the mutation TError.

useMutation defaults TError to Error, but if the underlying post() throws a non-Error value (e.g., a string or plain object from a fetch wrapper), err.message may be undefined and the toast renders Failed to bookmark: undefined. Either type the mutation explicitly or guard the access.

Proposed fix
-    onError: (err, bountyId, context) => {
+    onError: (err: Error, _bountyId, context) => {
       if (context) {
         queryClient.setQueryData(bookmarkKeys.list(), context.previousList);
         queryClient.setQueryData(bookmarkKeys.ids(), context.previousIds);
       }
-      toast.error(`Failed to bookmark: ${err.message}`);
+      const message = err instanceof Error ? err.message : "Unknown error";
+      toast.error(`Failed to bookmark: ${message}`);
     },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@hooks/use-bookmarks.ts` around lines 99 - 105, onError currently assumes
err.message exists which is unsafe; update the mutation error handling in the
onError callback (the one that calls queryClient.setQueryData,
bookmarkKeys.list(), bookmarkKeys.ids(), and toast.error) to safely extract the
error text instead of using err.message directly — either explicitly type the
mutation error generic (TError) so err is an Error, or more robustly guard and
normalize the value (e.g., compute const msg = typeof err === 'string' ? err :
err && typeof (err as any).message === 'string' ? (err as any).message :
String(err) ) and then call toast.error(`Failed to bookmark: ${msg}`) so the
toast never shows "undefined".
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/api/bookmarks/route.ts`:
- Around line 19-72: The route currently builds a custom BOOKMARKS_QUERY and
calls graphqlRequest<{ bookmarks: Bookmark[] }>, which diverges from the
generated bookmark operation (BookmarksDocument) that uses the canonical
fragment BountyFields and causes an unsound type assertion; replace the
hand‑rolled BOOKMARKS_QUERY and the manual generic on graphqlRequest with the
generated BookmarksDocument (or create/import a dedicated bookmark operation in
bookmark-operations.graphql), then call graphqlRequest with that generated
document and its generated types so the returned bounty shape matches
BountyFields and TypeScript types remain sound for useBookmarks and other
consumers.

In `@components/bounty/bounty-card.tsx`:
- Around line 121-125: The bookmark control inside the clickable Card is causing
click/keyboard events to bubble and trigger the Card's onClick/onKeyDown
navigation; wrap the BookmarkButton handler so it stops propagation for both
pointer and keyboard interactions: in the component containing <BookmarkButton
bountyId={bounty.id} size="sm" /> (the wrapper div around the BookmarkButton),
add event handlers that call event.stopPropagation() for onClick and for
onKeyDown (handling Space/Enter) to prevent the Card's onClick/onKeyDown from
firing when interacting with BookmarkButton.

In `@hooks/use-bookmarks.ts`:
- Around line 106-111: The onSettled handler in the mutation currently calls
Promise.all([...]) but doesn't return or await it, causing mutateAsync to
resolve before cache invalidations finish; update the onSettled in useBookmarks
(the mutation config using onSettled) to return the Promise from
Promise.all([...]) (or make the handler async and await the two
queryClient.invalidateQueries calls) so that invalidateQueries for
bookmarkKeys.list() and bookmarkKeys.ids() is awaited and any rejection is
propagated.

In `@hooks/use-notifications.ts`:
- Around line 90-106: getBookmarkedStatusMap currently returns an empty Map when
bookmarkKeys.ids() is populated but bookmarkKeys.list() is missing, which causes
first incoming bountyUpdated events to be treated as status changes; change
getBookmarkedStatusMap so that if ids exist but list is not fetched it returns
null/undefined (or another explicit sentinel) instead of an empty Map, and
update callers to check for that sentinel before generating notifications (i.e.,
only treat a missing previous status as “first observation” when the function
returns a real Map and contains the bountyId); reference getBookmarkedStatusMap,
queryClient.getQueryData, bookmarkKeys.ids() and bookmarkKeys.list() when making
this change.
- Around line 167-196: The handler currently detects a status-change and calls
addNotification but never updates the bookmark status cache, so subsequent
duplicate events see the old previousStatus and re-fire notifications; after
creating the notification (inside the isBookmarked check where you use
getBookmarkedStatusMap, addNotification and normaliseTimestamp), update the
bookmark status cache (bookmarkKeys.statusCache()) in the queryClient to record
bounty.id => bounty.status (e.g. by getting the current map/record and setting
the new status) so future events will compare against the last-seen status and
not produce duplicate "saved-bounty-updated" notifications; keep the existing
detail invalidation (bountyKeys.detail(bounty.id)) as is.

In `@lib/graphql/schema.graphql`:
- Around line 921-922: The GraphQL mutation toggleBookmark currently returns a
non-null Bookmark but the client expects null when removing a bookmark; update
the contract to allow null by changing the mutation signature to nullable
(toggleBookmark(input: ToggleBookmarkInput!): Bookmark), or alternatively
implement explicit addBookmark/removeBookmark mutations or a
ToggleBookmarkResult wrapper type (e.g., { bookmarked: Boolean!, bookmark:
Bookmark }). Make the matching change in the NestJS resolver decorators that
define the toggleBookmark return type in the upstream schema (boundless-nestjs),
regenerate the schema, and run npm run sync-schema so lib/graphql/schema.graphql
is updated to the chosen nullable/result form; ensure client-facing docs/types
(e.g., hooks/use-bookmarks) align with the new return type.

In `@package.json`:
- Line 87: The CI is failing because the lockfile is out of sync after adding
the dependency "@graphql-typed-document-node/core"; run pnpm install locally
(without --frozen-lockfile) to regenerate pnpm-lock.yaml, verify the lockfile
changes, and commit the updated pnpm-lock.yaml alongside the package.json change
so CI will pass `pnpm install --frozen-lockfile`.

---

Nitpick comments:
In `@app/api/bookmarks/`[bountyId]/route.ts:
- Around line 33-85: Replace the inline TOGGLE_BOOKMARK_MUTATION string with the
generated TypedDocument ToggleBookmarkDocument and call graphqlRequest with that
document (and the same variables) so this route uses the codegen-typed
operation; specifically, remove TOGGLE_BOOKMARK_MUTATION, import
ToggleBookmarkDocument, and pass ToggleBookmarkDocument to graphqlRequest
(preserving the { input: { bountyId } } payload and the expected response type)
so fields like bountyWindow and _count remain synced with the schema.

In `@app/api/bookmarks/ids/route.ts`:
- Around line 19-32: Replace the inline BOOKMARKS_QUERY and manual typing with
the generated document: import and use the existing BookmarksDocument (or add a
new BookmarkIds operation in bookmark-operations.graphql and use its generated
document) when calling graphqlRequest so you get codegen types instead of the
explicit generic; update the call site that currently references BOOKMARKS_QUERY
to pass the generated document (e.g., BookmarksDocument or BookmarkIdsDocument)
and then map data.bookmarks to extract bountyId as before.

In `@app/saved/page.tsx`:
- Around line 8-10: Update the redirect inside the authentication check so the
original path is preserved and the user returns to /saved after signing in: in
the if (!user) { redirect("/auth"); } block, change the redirect to include a
redirect query parameter containing the current request path (or '/saved' if you
prefer) and ensure the path is URL-encoded; use the same symbol names shown (the
if (!user) guard and redirect(...) call) so the auth page can read the query
(e.g. redirect=?redirect=...) and navigate back after successful signin.

In `@app/saved/saved-client.tsx`:
- Around line 64-72: Replace the hard navigation using window.location.href in
saved-client.tsx with Next.js SPA navigation: import and call useRouter from
'next/navigation' (const router = useRouter()) and replace the onClick handler
on BountyCard to router.push(`/bounty/${bounty.id}`); alternatively wrap the
BountyCard with next/link to the same path. Ensure the component is a client
component (has "use client") if using useRouter from next/navigation, remove
window.location.href usage, and keep the BountyCard's key and props unchanged.
- Around line 29-38: The Retry button currently calls window.location.reload()
which refreshes the whole app; change its onClick to call the refetch function
returned by useBookmarks instead so only the bookmarks query is retried. Locate
the component using useBookmarks (the hook and its refetch symbol) in
saved-client.tsx and update the Button onClick from window.location.reload() to
invoke refetch (e.g., onClick={() => refetch()}), and ensure refetch is
defined/returned from the useBookmarks call in the same scope.
- Around line 41-43: The filter uses a no-op type predicate and then force-casts
to BountyType, losing type safety; update the bookmarkedBounties computation to
either map directly to b.bounty when BookmarkType.bounty is non-nullable
(replace the filter+cast with (bookmarks ?? []).map(b => b.bounty)), or if the
API can actually return null bounties make the BookmarkType.bounty nullable and
use a real runtime guard (e.g., filter((b): b is BookmarkType & { bounty:
BountyType } => b.bounty != null).map(b => b.bounty)) so you avoid the unsafe as
BountyType cast while keeping bookmarkedBounties correctly typed.

In `@components/bounty/bookmark-button.tsx`:
- Around line 80-82: The prop cast on size is widening buttonSize incorrectly;
update the usage so the Button size prop receives the correct narrowed type
instead of the current cast. Replace the explicit cast on buttonSize with either
no cast (if Button.size already accepts "default" | "sm" | "lg" | "icon") or
map/convert the component's size union ("sm" | "md" | "lg" | "icon") to the
exact Button size union, ensuring symbols referenced are the local variable
buttonSize and the Button component's size prop so unreachable variants
"icon-sm" and "icon-lg" are not introduced.
- Around line 89-91: The loading state currently replaces the bookmark icon with
literal "..." causing layout and affordance regressions; update the
BookmarkButton component so when isLoading is true you keep the bookmark glyph
visible (the same markup used for the isBookmarked/isNotBookmarked branches) and
either reduce its opacity or overlay a Loader2 spinner with
className="animate-spin" instead of showing "..."; locate uses of isLoading and
isBookmarked inside the BookmarkButton render and replace the conditional that
renders "<span className=\"animate-pulse\">...</span>" so it renders the
bookmark icon + a Loader2 spinner (or the icon with reduced opacity) to preserve
layout and accessibility.

In `@components/ui/stellar-link.tsx`:
- Line 135: The change removing value from the dependency array of the
tooltipText computation is correct (tooltipText only depends on isValid, type,
network, explorer, tooltipPrefix), but it’s unrelated to the bookmark work in
this PR—either move this optimization into its own small PR or revert it here
and add it in a follow-up; reference the tooltipText computation and the
dependency array that now reads [type, network, explorer, isValid,
tooltipPrefix] in components/ui/stellar-link.tsx and ensure isValid continues to
reflect value changes so tooltip updates remain correct.

In `@hooks/use-bookmarks.ts`:
- Around line 112-119: onSuccess currently re-reads the optimistic cache
(bookmarkKeys.ids()) and casts variables to string to decide the toast; instead
use the server response (_result) returned by the mutation to determine whether
the bookmark exists and show the correct toast. Update the onSuccess handler in
useBookmarks (the mutation's onSuccess callback) to inspect _result (which is
Bookmark | null) and call toast.success with "Bounty bookmarked!" when _result
is non-null or "Bookmark removed" when null, remove the Set/check against
queryClient.getQueryData<string[]>(bookmarkKeys.ids()) and avoid the redundant
"as string" cast.
- Around line 99-105: onError currently assumes err.message exists which is
unsafe; update the mutation error handling in the onError callback (the one that
calls queryClient.setQueryData, bookmarkKeys.list(), bookmarkKeys.ids(), and
toast.error) to safely extract the error text instead of using err.message
directly — either explicitly type the mutation error generic (TError) so err is
an Error, or more robustly guard and normalize the value (e.g., compute const
msg = typeof err === 'string' ? err : err && typeof (err as any).message ===
'string' ? (err as any).message : String(err) ) and then call
toast.error(`Failed to bookmark: ${msg}`) so the toast never shows "undefined".

In `@lib/graphql/client.ts`:
- Around line 50-55: normalizeDocument currently converts non-string GraphQL
documents to a string via query.toString(), which strips TypedDocumentString
metadata like __meta__ and typed parameter/result info; update normalizeDocument
to return the original query object unchanged when it is a TypedDocumentString
(or otherwise carries __meta__) so downstream middleware can access its
metadata, i.e., detect the TypedDocumentString shape (presence of __meta__ or
instanceof TypedDocumentString) and return query directly instead of
query.toString(), while still falling back to toString() for other non-string
document types.
- Around line 75-83: The double-cast on requestOptions should be removed and
replaced with a narrower, explicit typing and a short comment documenting the
graphql-request overload constraint being worked around: construct the object
into a variable named requestOptions and either declare it with the exact type
RequestOptions<TVariables, TData> (or use a Partial-to-complete pattern) so the
compiler enforces shape for document/variables/requestHeaders, and only resort
to a single, justified cast if an overload prevents a clean assignment; add a
one-line comment referencing the graphql-request overload issue (e.g.,
"workaround for graphql-request RequestOptions overloads requiring different
shapes for documents with required variables") so future maintainers don't widen
the type hole, and keep graphQLClient.request<TData, TVariables>(requestOptions)
as the call site.

In `@lib/server-graphql.ts`:
- Around line 21-31: The fetch call that posts to GRAPHQL_URL in the GraphQL
request (where you pass method, headers, body, cache) has no timeout and can
hang; add a timeout AbortSignal to the fetch options (e.g., signal:
AbortSignal.timeout(10_000)) so the request fails fast, and handle the resulting
abort/timeout error path before the existing response.ok check to surface a 5xx
or appropriate error from the bookmark API.
- Around line 29-38: The current GraphQL response handling in
lib/server-graphql.ts loses important error details: when response.ok is false
you only throw response.status and drop the response body, and when json.errors
exists you only throw json.errors[0]?.message ignoring extensions.code/status.
Fix this by creating/using a custom error type (e.g., GraphQLErrorWithStatus)
and populate it with the full response body and HTTP status when response.ok is
false, and when json.errors exists include the first error's message plus its
extensions.code (or map extensions.code to an HTTP status) and attach both the
GraphQL errors array and a status field before throwing; update the throw sites
in the response handling block to throw that enriched error so callers (like the
route handler) can map to the correct HTTP code.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 434376aa-44cf-4a94-92e1-80b28c968d04

📥 Commits

Reviewing files that changed from the base of the PR and between ed167b6 and 54a5adb.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (19)
  • app/api/bookmarks/[bountyId]/route.ts
  • app/api/bookmarks/ids/route.ts
  • app/api/bookmarks/route.ts
  • app/saved/page.tsx
  • app/saved/saved-client.tsx
  • codegen.ts
  • components/bounty-detail/bounty-detail-header-card.tsx
  • components/bounty/bookmark-button.tsx
  • components/bounty/bounty-card.tsx
  • components/ui/stellar-link.tsx
  • hooks/use-bookmarks.ts
  • hooks/use-notifications.ts
  • lib/graphql/client.ts
  • lib/graphql/generated.ts
  • lib/graphql/operations/bookmark-operations.graphql
  • lib/graphql/schema.graphql
  • lib/query/query-keys.ts
  • lib/server-graphql.ts
  • package.json

Comment thread app/api/bookmarks/route.ts Outdated
Comment thread components/bounty/bounty-card.tsx
Comment thread hooks/use-bookmarks.ts
Comment thread hooks/use-notifications.ts
Comment thread hooks/use-notifications.ts
Comment thread lib/graphql/schema.graphql Outdated
Comment thread package.json
Ensure notification deduplication cache is both written to and read from.
Previously, statusCache was updated but never used, causing repeated
notifications for identical bounty status updates.

Now:
- Read from statusCache as primary baseline
- Fallback to list-derived status when absent
- Skip notifications when status hasn't changed
- Maintain cache consistency after emitting notification

Also confirms:
- GraphQL generated documents are used across bookmark routes
- React Query onSettled properly awaits cache invalidation
- Notification false positives avoided when cache is uninitialized

@Benjtalkshow Benjtalkshow left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Hey @Shadow-MMN

Two blockers before this can merge.

The PR has a merge conflict in components/bounty/bounty-card.tsx that needs to be resolved.

CI is failing because of the JSR install issue (#187), which is also fixed on main now via #177. Pull from main to solve this.

A couple of smaller things to address while you're in there.

The bookmark button click is propagation-stopped in handleToggle via e.stopPropagation(), but only for mouse clicks. Keyboard activation (Space/Enter on the bookmark button) fires a keydown event that bubbles to the parent card, where onKeyDown handles Space/Enter and triggers navigation. CodeRabbit flagged this and the suggested fix was to add onClick and onKeyDown stop-propagation handlers on the wrapper div in bounty-card.tsx, not just inside the BookmarkButton. Same fix would help on the detail header card.

useBookmarks and useBookmarkIds both use staleTime: 5 minutes but they're independent queries fetched at different times, so the two caches go stale at slightly different points. If a user opens /saved after browsing for a while, the ids cache may invalidate before list (or vice versa), producing a brief desync between the bookmark icons and the saved page. Either align them on the same trigger or have one derive from the other rather than fetching twice.

Once the conflict is resolved and the keyboard propagation is fixed, this should be ready. Ping me when CI is green.

@Shadow-MMN

Copy link
Copy Markdown
Contributor Author

Ok on it

@Shadow-MMN

Copy link
Copy Markdown
Contributor Author

@Benjtalkshow , can you have a look now

@Shadow-MMN
Shadow-MMN requested a review from Benjtalkshow April 25, 2026 15:54

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (1)
hooks/use-notifications.ts (1)

183-218: ⚠️ Potential issue | 🟠 Major

bookmarkKeys.statusCache() is written but never read — duplicate notifications still possible.

previousStatus is sourced exclusively from statusMap (derived from bookmarkKeys.list() cache), and the list cache is not updated after firing the notification — only bountyKeys.detail(bounty.id) is invalidated. So on a duplicate bountyUpdated event (subscription replay on reconnect, server re-publish, etc.) statusMap.get(bounty.id) returns the same pre-change status, the inequality check is true again, and a second saved-bounty-updated notification is appended with a fresh Date.now()-based id that upsertNotification cannot dedupe.

The bookmarkKeys.statusCache() write at lines 211-217 was added for exactly this purpose but is never read. Either source previousStatus from it (preferred) or also patch the list cache after notifying:

🛠️ Suggested fix — read from statusCache first
         if (isBookmarked) {
-          // Get previous status from status map (derived from list cache)
-          const statusMap = getBookmarkedStatusMap(queryClient);
-
-          // If no cached data, skip notification (avoid false positives)
-          if (statusMap === null) {
-            return;
-          }
-
-          const previousStatus = statusMap.get(bounty.id);
+          // Prefer the dedicated dedupe cache; fall back to list-derived
+          // status only on first observation.
+          const statusCache =
+            queryClient.getQueryData<Record<string, string>>(
+              bookmarkKeys.statusCache(),
+            ) ?? {};
+          const statusMap = getBookmarkedStatusMap(queryClient);
+          const previousStatus =
+            statusCache[bounty.id] ?? statusMap?.get(bounty.id);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@hooks/use-notifications.ts` around lines 183 - 218, The code reads
previousStatus from statusMap (via getBookmarkedStatusMap) but never reads the
dedicated cache written to by bookmarkKeys.statusCache(), so duplicate
notifications can still occur; change the check to source previousStatus by
first reading queryClient.getQueryData(bookmarkKeys.statusCache()) (or a helper
that wraps that), falling back to statusMap/list cache only if the statusCache
is null/undefined, and keep the existing
queryClient.setQueryData(bookmarkKeys.statusCache(), ...) update after
addNotification so the canonical statusCache is always updated; reference
getBookmarkedStatusMap, previousStatus, bookmarkKeys.statusCache(),
addNotification and queryClient.setQueryData when making the change.
🧹 Nitpick comments (1)
hooks/use-bookmarks.ts (1)

60-129: Toggle implementation looks solid; consider deriving the success toast from the server response.

The optimistic strategy (ids authoritative, list derivative, atomic rollback, awaited invalidation) is implemented correctly and prior onSettled await issue is resolved.

One small robustness nit: onSuccess decides the toast text by re-reading bookmarkKeys.ids(). If the user double-toggles quickly, a second onMutate may have already flipped the optimistic state by the time the first mutation's onSuccess runs, producing a toast that contradicts what the first server call actually did. Since mutationFn already returns Bookmark | null (non-null on add, null on remove), the result itself is the unambiguous signal:

♻️ Optional: derive toast from server response
-    onSuccess: (_result, variables) => {
-      // Source of truth: check current IDs cache using Set for O(1)
-      const currentIds =
-        queryClient.getQueryData<string[]>(bookmarkKeys.ids()) ?? [];
-      const currentIdsSet = new Set(currentIds);
-      const isBookmarked = currentIdsSet.has(variables as string);
-      toast.success(isBookmarked ? "Bounty bookmarked!" : "Bookmark removed");
-    },
+    onSuccess: (result) => {
+      toast.success(result ? "Bounty bookmarked!" : "Bookmark removed");
+    },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@hooks/use-bookmarks.ts` around lines 60 - 129, The onSuccess currently infers
bookmark state by re-reading bookmarkKeys.ids(), which can be wrong if another
optimistic toggle occurred; update useToggleBookmark's onSuccess to derive the
toast directly from the mutation result returned by mutationFn (the Bookmark |
null value) instead of querying the cache—i.e., use the result param (rename
_result to result if needed) and show "Bounty bookmarked!" when result is
non-null, otherwise "Bookmark removed".
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@hooks/use-notifications.ts`:
- Around line 182-219: The early `return` inside the isBookmarked branch causes
the whole handler to exit and prevents the unconditional invalidation of
bountyKeys.detail(bounty.id); instead of returning when statusMap === null,
limit the skip to the notification logic: replace the `if (statusMap === null) {
return; }` pattern with a conditional that only skips the notification (e.g.,
`if (statusMap !== null) { /* existing notification + setQueryData logic using
previousStatus */ }`) so the rest of the handler still executes, then leave the
existing `queryClient.invalidateQueries({ queryKey: bountyKeys.detail(bounty.id)
})` call intact; keep references to getBookmarkedStatusMap, previousStatus,
addNotification, queryClient.setQueryData and bountyKeys.detail to locate and
update the code.

---

Duplicate comments:
In `@hooks/use-notifications.ts`:
- Around line 183-218: The code reads previousStatus from statusMap (via
getBookmarkedStatusMap) but never reads the dedicated cache written to by
bookmarkKeys.statusCache(), so duplicate notifications can still occur; change
the check to source previousStatus by first reading
queryClient.getQueryData(bookmarkKeys.statusCache()) (or a helper that wraps
that), falling back to statusMap/list cache only if the statusCache is
null/undefined, and keep the existing
queryClient.setQueryData(bookmarkKeys.statusCache(), ...) update after
addNotification so the canonical statusCache is always updated; reference
getBookmarkedStatusMap, previousStatus, bookmarkKeys.statusCache(),
addNotification and queryClient.setQueryData when making the change.

---

Nitpick comments:
In `@hooks/use-bookmarks.ts`:
- Around line 60-129: The onSuccess currently infers bookmark state by
re-reading bookmarkKeys.ids(), which can be wrong if another optimistic toggle
occurred; update useToggleBookmark's onSuccess to derive the toast directly from
the mutation result returned by mutationFn (the Bookmark | null value) instead
of querying the cache—i.e., use the result param (rename _result to result if
needed) and show "Bounty bookmarked!" when result is non-null, otherwise
"Bookmark removed".
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 33f00965-551b-4667-8326-fb71dfbe276f

📥 Commits

Reviewing files that changed from the base of the PR and between 54a5adb and d1b9695.

📒 Files selected for processing (9)
  • app/api/bookmarks/[bountyId]/route.ts
  • app/api/bookmarks/route.ts
  • components/bounty-detail/bounty-detail-header-card.tsx
  • components/bounty/bounty-card.tsx
  • hooks/use-bookmarks.ts
  • hooks/use-notifications.ts
  • lib/graphql/schema.graphql
  • lib/server-graphql.ts
  • package.json
✅ Files skipped from review due to trivial changes (1)
  • package.json
🚧 Files skipped from review as they are similar to previous changes (4)
  • components/bounty-detail/bounty-detail-header-card.tsx
  • app/api/bookmarks/[bountyId]/route.ts
  • lib/server-graphql.ts
  • lib/graphql/schema.graphql

Comment thread hooks/use-notifications.ts

@Benjtalkshow Benjtalkshow left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Almost there.
One thing to fix before merge: useBookmarks and useBookmarkIds both declare staleTime: 1000 * 60 * 5 independently (lines 25 and 44 of hooks/use-bookmarks.ts). Since they're fetched at different times the two caches will go stale at slightly different points, which is exactly the kind of desync the rest of this PR is built to avoid. Lift the value into a shared constant at the top of the file (e.g. const BOOKMARK_STALE_TIME = 5 * 60 * 1000;) and reference it from both hooks.

Once that's in, this is good to merge.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

♻️ Duplicate comments (1)
hooks/use-notifications.ts (1)

182-217: ⚠️ Potential issue | 🟠 Major

Notification dedup is broken: statusCache is written but never read.

The list cache (bookmarkKeys.list()) is not invalidated by a bountyUpdated event — only bountyKeys.detail(bounty.id) is invalidated at line 219. So statusMap (derived from the list cache via getBookmarkedStatusMap) keeps returning the stale pre-change status indefinitely (until the list is refetched on staleTime expiry or a bookmark toggle).

Trace of a duplicate event:

  1. Initial list cache: bounty.status === "OPEN".
  2. Subscription event arrives with status === "IN_REVIEW". previousStatus = "OPEN""IN_REVIEW" → notification fired, statusCache[id] = "IN_REVIEW" written.
  3. Duplicate event arrives with status === "IN_REVIEW". statusMap.get(id) still returns "OPEN" (list cache untouched), so previousStatus !== bounty.status is again true → duplicate notification fired.

Because the notification id includes Date.now() (line 198), upsertNotification's key-based dedupe cannot collapse the duplicates either. The setQueryData write at lines 208-214 is effectively dead code.

Resolve previousStatus from bookmarkKeys.statusCache() first and fall back to the list-derived status only as a "first observation" seed:

🛠️ Proposed fix
         if (isBookmarked) {
-          // Get previous status from status map (derived from list cache)
-          const statusMap = getBookmarkedStatusMap(queryClient);
-
-          // Only process notification if cached data exists (avoid false positives)
-          if (statusMap !== null) {
-            const previousStatus = statusMap.get(bounty.id);
-
-            // Only notify if status changed (meaningful update)
-            if (
-              previousStatus === undefined ||
-              previousStatus !== bounty.status
-            ) {
+          // Authoritative dedupe cache; falls back to list-derived status as a
+          // seed for the first observation.
+          const statusCache =
+            queryClient.getQueryData<Record<string, string>>(
+              bookmarkKeys.statusCache(),
+            ) ?? {};
+          const seededStatus =
+            getBookmarkedStatusMap(queryClient)?.get(bounty.id);
+          const previousStatus = statusCache[bounty.id] ?? seededStatus;
+
+          // Only notify if we have a known previous status that differs from
+          // the incoming one (avoids first-event-after-bookmark false positives
+          // and dedupes duplicate publishes/reconnect replays).
+          if (
+            previousStatus !== undefined &&
+            previousStatus !== bounty.status
+          ) {
               const timestamp = Date.now();
               addNotification(
                 {
                   id: `saved-bounty-updated-${bounty.id}-${timestamp}`,
                   message: `Saved bounty "${bounty.title}" was updated.`,
                   type: "saved-bounty-updated",
                   timestamp: normaliseTimestamp(bounty.updatedAt),
                   read: false,
                 },
                 [],
               );
-
-              // Update status cache to prevent duplicate notifications
-              queryClient.setQueryData<Record<string, string>>(
-                bookmarkKeys.statusCache(),
-                (old = {}) => ({
-                  ...old,
-                  [bounty.id]: bounty.status,
-                }),
-              );
-            }
           }
+
+          // Always record last-seen status (even on the first observation) so
+          // future events can be deduped reliably.
+          queryClient.setQueryData<Record<string, string>>(
+            bookmarkKeys.statusCache(),
+            (old = {}) => ({
+              ...old,
+              [bounty.id]: bounty.status,
+            }),
+          );
         }

Note: gating on previousStatus !== undefined (instead of treating undefined as a status change) avoids spurious "saved-bounty-updated" notifications on the very first event after a user bookmarks a bounty. If the product intent is to notify on the first event regardless, keep the === undefined branch but still move the dedupe read to statusCache.

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

In `@hooks/use-notifications.ts` around lines 182 - 217, The current dedupe reads
the list-derived status via getBookmarkedStatusMap but never reads the persisted
status cache written to bookmarkKeys.statusCache(), causing duplicates; change
the logic in the bounty update handler to first read previousStatus from
queryClient.getQueryData(bookmarkKeys.statusCache()) (use that value as the
authoritative previousStatus), only fall back to the list-derived map
(getBookmarkedStatusMap) when the cache entry is missing to seed an initial
value, and then only call addNotification when previousStatus !== bounty.status
(or if your product wants first-observation notifications, keep the undefined
branch but still seed/write to bookmarkKeys.statusCache()); ensure you still
update bookmarkKeys.statusCache() via queryClient.setQueryData after notifying
so future events use the cache.
🧹 Nitpick comments (1)
hooks/use-bookmarks.ts (1)

38-48: Consider initialDataUpdatedAt and using b.bountyId directly.

Two optional improvements on the ids query:

  1. With initialData set, React Query treats it as freshly fetched relative to staleTime. If bookmarkKeys.list() was cached ~5 minutes ago, useBookmarkIds() will inherit that data as "just fetched" and skip a real fetch for another 5 minutes. Pass initialDataUpdatedAt to inherit the list query's timestamp (so freshness is correctly tracked), or use placeholderData if you don't want it persisted to the cache.
  2. Lines 89 and 100 use b.bountyId; line 46 uses b.bounty.id. Both reference the same value, but use the direct field for consistency.
Proposed change
 export function useBookmarkIds() {
   const queryClient = useQueryClient();
-  const listData = queryClient.getQueryData<Bookmark[]>(bookmarkKeys.list());
+  const listState = queryClient.getQueryState<Bookmark[]>(bookmarkKeys.list());
+  const listData = listState?.data;

   return useQuery<string[]>({
     queryKey: bookmarkKeys.ids(),
     queryFn: fetchBookmarkIds,
     staleTime: BOOKMARK_STALE_TIME,
-    initialData: listData?.map((b) => b.bounty.id) ?? undefined,
+    initialData: listData?.map((b) => b.bountyId) ?? undefined,
+    initialDataUpdatedAt: listState?.dataUpdatedAt,
   });
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@hooks/use-bookmarks.ts` around lines 38 - 48, The ids query in useBookmarkIds
currently sets initialData to listData?.map(b => b.bounty.id) which incorrectly
marks that data as freshly fetched and also uses b.bounty.id inconsistently;
update useBookmarkIds to either (A) pass initialDataUpdatedAt using the
timestamp from the list query
(queryClient.getQueryState(bookmarkKeys.list())?.dataUpdatedAt) so freshness is
inherited while keeping initialData, or (B) switch to placeholderData instead of
initialData if you don't want to persist it, and replace the mapping to use
b.bountyId (listData?.map(b => b.bountyId)) for consistency; keep queryKey
bookmarkKeys.ids(), queryFn fetchBookmarkIds and stale time BOOKMARK_STALE_TIME
unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@hooks/use-notifications.ts`:
- Around line 182-217: The current dedupe reads the list-derived status via
getBookmarkedStatusMap but never reads the persisted status cache written to
bookmarkKeys.statusCache(), causing duplicates; change the logic in the bounty
update handler to first read previousStatus from
queryClient.getQueryData(bookmarkKeys.statusCache()) (use that value as the
authoritative previousStatus), only fall back to the list-derived map
(getBookmarkedStatusMap) when the cache entry is missing to seed an initial
value, and then only call addNotification when previousStatus !== bounty.status
(or if your product wants first-observation notifications, keep the undefined
branch but still seed/write to bookmarkKeys.statusCache()); ensure you still
update bookmarkKeys.statusCache() via queryClient.setQueryData after notifying
so future events use the cache.

---

Nitpick comments:
In `@hooks/use-bookmarks.ts`:
- Around line 38-48: The ids query in useBookmarkIds currently sets initialData
to listData?.map(b => b.bounty.id) which incorrectly marks that data as freshly
fetched and also uses b.bounty.id inconsistently; update useBookmarkIds to
either (A) pass initialDataUpdatedAt using the timestamp from the list query
(queryClient.getQueryState(bookmarkKeys.list())?.dataUpdatedAt) so freshness is
inherited while keeping initialData, or (B) switch to placeholderData instead of
initialData if you don't want to persist it, and replace the mapping to use
b.bountyId (listData?.map(b => b.bountyId)) for consistency; keep queryKey
bookmarkKeys.ids(), queryFn fetchBookmarkIds and stale time BOOKMARK_STALE_TIME
unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 247f0907-ac1a-49d6-9a55-1600b261a738

📥 Commits

Reviewing files that changed from the base of the PR and between d1b9695 and 92fb361.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (2)
  • hooks/use-bookmarks.ts
  • hooks/use-notifications.ts

@Benjtalkshow Benjtalkshow left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM!

@Benjtalkshow
Benjtalkshow merged commit c96da5e into boundlessfi:main Apr 25, 2026
2 of 3 checks passed
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.

Feature: Saved / Bookmarked Bounties

2 participants