Skip to content
Open
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
272 changes: 187 additions & 85 deletions hooks/use-bounty-mutations.ts
Original file line number Diff line number Diff line change
@@ -1,98 +1,200 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
bountiesApi,
type Bounty,
type CreateBountyInput,
type UpdateBountyInput,
type PaginatedResponse,
} from '@/lib/api';
import { bountyKeys } from './use-bounties';
type Bounty,
type CreateBountyInput,
type UpdateBountyInput,
type PaginatedResponse,
} from "@/lib/api";
import { fetcher } from "@/lib/graphql/client";
import { bountyKeys } from "@/lib/query/query-keys";
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# Search for all occurrences of bountyKeys to check for duplication
rg "bountyKeys" --type ts -B 2 -A 5

Repository: boundlessfi/bounties

Length of output: 17574


🏁 Script executed:

# Find and examine the UpdateBountyInput type definition
rg "type UpdateBountyInput|interface UpdateBountyInput" --type ts -A 8

Repository: boundlessfi/bounties

Length of output: 1266


🏁 Script executed:

# Check the complete structure of hooks/use-bounties.ts to see what it exports
fd "use-bounties.ts" --type f -x head -30 {}

Repository: boundlessfi/bounties

Length of output: 752


🏁 Script executed:

# Check the content of `@/lib/query/query-keys` to confirm bountyKeys definition there
fd "query-keys" --type ts -x cat {}

Repository: boundlessfi/bounties

Length of output: 233


🏁 Script executed:

# Find the updateBountySchema to understand UpdateBountyInput type structure
rg "updateBountySchema|const updateBountySchema" -A 15

Repository: boundlessfi/bounties

Length of output: 1525


🏁 Script executed:

# Check if hooks/use-bounties.ts was updated to import bountyKeys from the shared module
cat hooks/use-bounties.ts | head -20

Repository: boundlessfi/bounties

Length of output: 752


🏁 Script executed:

# Verify if both bountyKeys have the same structure (especially the .infinite method)
rg "infinite:" lib/query/query-keys.ts hooks/use-bounties.ts -B 2 -A 2

Repository: boundlessfi/bounties

Length of output: 565


🏁 Script executed:

# Check lib/query/sync/handlers.ts to see which bountyKeys it's importing
head -10 lib/query/sync/handlers.ts

Repository: boundlessfi/bounties

Length of output: 478


🏁 Script executed:

# Find createBountySchema to understand what UpdateBountyInput contains for status
rg "createBountySchema.*=" -A 20 lib/api/bounties.ts

Repository: boundlessfi/bounties

Length of output: 714


🏁 Script executed:

# Check if any code is using the .infinite method which differs between the two bountyKeys definitions
rg "bountyKeys\.infinite" --type-list | head -20

Repository: boundlessfi/bounties

Length of output: 453


🏁 Script executed:

# Verify the actual import pattern in hooks/use-infinite-bounties.ts
cat hooks/use-infinite-bounties.ts | head -20

Repository: boundlessfi/bounties

Length of output: 1016


🏁 Script executed:

# Check the bountySchema to understand what fields it has, especially status
rg "bountySchema\s*=" -A 25 lib/api/bounties.ts | head -35

Repository: boundlessfi/bounties

Length of output: 888


🏁 Script executed:

# Verify which files import bountyKeys from hooks/use-bounties (the old location) vs lib/query/query-keys (correct)
rg "from.*['\"]\.?/?use-bounties['\"]" --no-heading

Repository: boundlessfi/bounties

Length of output: 276


🏁 Script executed:

# Look at the exact lines around useDeleteBounty's onSettled to confirm the unconditional removeQueries
sed -n '200,215p' hooks/use-bounty-mutations.ts

Repository: boundlessfi/bounties

Length of output: 514


Remove the duplicate bountyKeys definition from hooks/use-bounties.ts; this file still exports its own version, while the canonical definition now lives in @/lib/query/query-keys.ts.

Multiple hooks and the sync handler still import from the old location in hooks/use-bounties.ts (e.g., hooks/use-infinite-bounties.ts, hooks/use-bounty.ts, hooks/Use-bounty-detail.ts, and critically lib/query/sync/handlers.ts). This creates two conflicting bountyKeys instances with different structure: the old one lacks the .infinite() method. Update all imports to use @/lib/query/query-keys instead, then delete the duplicate export from hooks/use-bounties.ts.

Additionally, in useDeleteBounty (line 205–208), the removeQueries call on the detail cache executes unconditionally in onSettled, which fires on both success and error. On error, this clears the cache for a bounty that still exists on the server. Consider removing the detail cache only on success, or add a check for error state.

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

In `@hooks/use-bounty-mutations.ts` at line 9, Remove the duplicate bountyKeys
export from hooks/use-bounties.ts and update all imports to reference the
canonical bountyKeys in "@/lib/query/query-keys" (fix usages in
hooks/use-infinite-bounties.ts, hooks/use-bounty.ts, hooks/Use-bounty-detail.ts
and lib/query/sync/handlers.ts) so there is a single source that includes
.infinite(); then in the useDeleteBounty mutation handler (function
useDeleteBounty) change the onSettled logic that currently calls
detailCache.removeQueries(...) unconditionally to only clear the detail cache on
success (or check the error argument and skip removal when error is present) to
avoid wiping a still-existing bounty on failed deletes.


const CREATE_BOUNTY_MUTATION = `
mutation CreateBounty($input: CreateBountyInput!) {
createBounty(input: $input) {
id
}
}
`;

const UPDATE_BOUNTY_MUTATION = `
mutation UpdateBounty($input: UpdateBountyInput!) {
updateBounty(input: $input) {
id
status
updatedAt
}
}
`;

const DELETE_BOUNTY_MUTATION = `
mutation DeleteBounty($id: ID!) {
deleteBounty(id: $id)
}
`;

type CreateBountyMutationResponse = {
createBounty: Bounty;
};

type UpdateBountyMutationResponse = {
updateBounty: Bounty;
};

type DeleteBountyMutationResponse = {
deleteBounty: boolean;
};

type UpdateBountyMutationInput = Omit<UpdateBountyInput, "status"> & {
id: string;
status?: string;
};

async function createBountyMutation(input: CreateBountyInput): Promise<Bounty> {
const response = await fetcher<
CreateBountyMutationResponse,
{ input: CreateBountyInput }
>(CREATE_BOUNTY_MUTATION, { input })();

return response.createBounty;
}

async function updateBountyMutation(
input: UpdateBountyMutationInput,
): Promise<Bounty> {
const response = await fetcher<
UpdateBountyMutationResponse,
{ input: UpdateBountyMutationInput }
>(UPDATE_BOUNTY_MUTATION, { input })();

return response.updateBounty;
}

async function deleteBountyMutation(id: string): Promise<void> {
await fetcher<DeleteBountyMutationResponse, { id: string }>(
DELETE_BOUNTY_MUTATION,
{ id },
)();
}

export function useCreateBounty() {
const queryClient = useQueryClient();

return useMutation({
mutationFn: (data: CreateBountyInput) => bountiesApi.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: bountyKeys.lists() });
},
});
const queryClient = useQueryClient();

return useMutation({
mutationFn: createBountyMutation,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: bountyKeys.lists() });
},
});
}

export function useUpdateBounty() {
const queryClient = useQueryClient();

return useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateBountyInput }) =>
bountiesApi.update(id, data),
onMutate: async ({ id, data }) => {
await queryClient.cancelQueries({ queryKey: bountyKeys.detail(id) });
const previous = queryClient.getQueryData<Bounty>(bountyKeys.detail(id));

if (previous) {
queryClient.setQueryData<Bounty>(bountyKeys.detail(id), {
...previous,
...data,
updatedAt: new Date().toISOString(),
});
}

return { previous, id };
},
onError: (_err, _vars, context) => {
if (context?.previous) {
queryClient.setQueryData(bountyKeys.detail(context.id), context.previous);
}
},
onSettled: (_data, _err, { id }) => {
queryClient.invalidateQueries({ queryKey: bountyKeys.detail(id) });
queryClient.invalidateQueries({ queryKey: bountyKeys.lists() });
},
});
const queryClient = useQueryClient();

return useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateBountyInput }) =>
updateBountyMutation({ id, ...data }),
onMutate: async ({ id, data }) => {
await queryClient.cancelQueries({ queryKey: bountyKeys.detail(id) });
const previous = queryClient.getQueryData<Bounty>(bountyKeys.detail(id));

if (previous) {
queryClient.setQueryData<Bounty>(bountyKeys.detail(id), {
...previous,
...data,
updatedAt: new Date().toISOString(),
});
}

return { previous, id };
},
onError: (_err, _vars, context) => {
if (context?.previous) {
queryClient.setQueryData(
bountyKeys.detail(context.id),
context.previous,
);
}
},
onSettled: (_data, _err, { id }) => {
queryClient.invalidateQueries({ queryKey: bountyKeys.detail(id) });
queryClient.invalidateQueries({ queryKey: bountyKeys.lists() });
},
});
}

export function useDeleteBounty() {
const queryClient = useQueryClient();

return useMutation({
mutationFn: (id: string) => bountiesApi.delete(id),
onMutate: async (id) => {
await queryClient.cancelQueries({ queryKey: bountyKeys.lists() });

const previousLists = queryClient.getQueriesData<PaginatedResponse<Bounty>>({
queryKey: bountyKeys.lists(),
});

queryClient.setQueriesData<PaginatedResponse<Bounty>>(
{ queryKey: bountyKeys.lists() },
(old) => old ? {
...old,
data: old.data.filter((b) => b.id !== id),
pagination: { ...old.pagination, total: old.pagination.total - 1 },
} : old
);

return { previousLists };
},
onError: (_err, _id, context) => {
context?.previousLists.forEach(([key, data]) => {
queryClient.setQueryData(key, data);
});
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: bountyKeys.lists() });
},
});
const queryClient = useQueryClient();

return useMutation({
mutationFn: deleteBountyMutation,
onMutate: async (id) => {
await queryClient.cancelQueries({ queryKey: bountyKeys.lists() });

const previousLists = queryClient.getQueriesData<
PaginatedResponse<Bounty>
>({
queryKey: bountyKeys.lists(),
});

queryClient.setQueriesData<PaginatedResponse<Bounty>>(
{ queryKey: bountyKeys.lists() },
(old) =>
old
? {
...old,
data: old.data.filter((b) => b.id !== id),
pagination: {
...old.pagination,
total: old.pagination.total - 1,
},
}
: old,
);

return { previousLists };
},
onError: (_err, _id, context) => {
context?.previousLists.forEach(([key, data]) => {
queryClient.setQueryData(key, data);
});
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: bountyKeys.lists() });
},
Comment on lines +205 to +210
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

removeQueries fires unconditionally in onSettled, stripping the detail cache even on a failed deletion.

onSettled receives both data and error as parameters, so it fires on failure as well. When the delete mutation fails, onError (line 200) correctly restores the list data, but onSettled then calls queryClient.removeQueries({ queryKey: bountyKeys.detail(id) }) regardless. This evicts the detail cache entry for a bounty that still exists on the server, causing any mounted detail view to enter a loading state and trigger an unnecessary refetch.

🛠️ Proposed fix — gate `removeQueries` on success
-  onSettled: (_data, _err, id) => {
-    queryClient.removeQueries({ queryKey: bountyKeys.detail(id) });
-    queryClient.invalidateQueries({ queryKey: bountyKeys.lists() });
-  },
+  onSettled: (_data, _err, id) => {
+    if (!_err) {
+      queryClient.removeQueries({ queryKey: bountyKeys.detail(id) });
+    }
+    queryClient.invalidateQueries({ queryKey: bountyKeys.lists() });
+  },
📝 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
onSettled: (_data, _err, id) => {
queryClient.removeQueries({ queryKey: bountyKeys.detail(id) });
queryClient.invalidateQueries({ queryKey: bountyKeys.lists() });
},
onSettled: (_data, _err, id) => {
if (!_err) {
queryClient.removeQueries({ queryKey: bountyKeys.detail(id) });
}
queryClient.invalidateQueries({ queryKey: bountyKeys.lists() });
},
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@hooks/use-bounty-mutations.ts` around lines 205 - 208, The onSettled handler
unconditionally calls queryClient.removeQueries({ queryKey:
bountyKeys.detail(id) }) even when the delete fails; change onSettled (in the
same mutation where onError restores list data) to only remove the detail cache
when the mutation succeeded by checking the error/data parameters (e.g., only
call queryClient.removeQueries when _err is falsy or data is present), leaving
the invalidateQueries({ queryKey: bountyKeys.lists() }) behavior as-is.

});
}

export function useClaimBounty() {
const queryClient = useQueryClient();

return useMutation({
mutationFn: (id: string) => bountiesApi.claim(id),
onSuccess: (data, id) => {
queryClient.setQueryData(bountyKeys.detail(id), data);
queryClient.invalidateQueries({ queryKey: bountyKeys.lists() });
},
});
const queryClient = useQueryClient();

return useMutation({
mutationFn: (id: string) =>
updateBountyMutation({ id, status: "IN_PROGRESS" }),
onMutate: async (id) => {
await queryClient.cancelQueries({ queryKey: bountyKeys.detail(id) });
const previous = queryClient.getQueryData<Bounty>(bountyKeys.detail(id));

if (previous) {
queryClient.setQueryData<Bounty>(bountyKeys.detail(id), {
...previous,
status: "claimed",
updatedAt: new Date().toISOString(),
});
}

return { previous, id };
},
onError: (_err, _id, context) => {
if (context?.previous) {
queryClient.setQueryData(
bountyKeys.detail(context.id),
context.previous,
);
}
},
onSettled: (_data, _err, id) => {
queryClient.invalidateQueries({ queryKey: bountyKeys.detail(id) });
queryClient.invalidateQueries({ queryKey: bountyKeys.lists() });
},
});
}