diff --git a/apps/client/src/hooks/use-likes.test.tsx b/apps/client/src/hooks/use-likes.test.tsx
index dcf953be..cf32f9af 100644
--- a/apps/client/src/hooks/use-likes.test.tsx
+++ b/apps/client/src/hooks/use-likes.test.tsx
@@ -23,6 +23,10 @@ function makeWrapper() {
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
})
client.setQueryData(queryKeys.posts.detail('s'), post)
+ // The feed's own cached copy of this post, under one filter set — this is
+ // what stayed stale until a hard refresh before the fix, since the list has
+ // a 5-minute staleTime and navigating back to it never triggered a refetch.
+ client.setQueryData(queryKeys.posts.list({}), [post])
const wrapper = ({ children }: { children: React.ReactNode }) => (
{children}
)
@@ -57,4 +61,35 @@ describe('useLikePost', () => {
await waitFor(() => expect(result.current.isError).toBe(true))
expect(client.getQueryData(queryKeys.posts.detail('s'))?.likeCount).toBe(2)
})
+
+ // REGRESSION: liking a post, then navigating back to the feed, showed the
+ // pre-like count until a hard refresh. The detail cache updated; the feed's
+ // own cached list — a separate copy of the same likeCount — did not.
+ it("also bumps the feed's cached copy of this post, so navigating back shows the new count", async () => {
+ vi.stubGlobal('fetch', vi.fn(() => new Promise(() => {}))) // never resolves
+ const { client, wrapper } = makeWrapper()
+ const { result } = renderHook(() => useLikePost('s'), { wrapper })
+ result.current.mutate()
+ await waitFor(() => {
+ const list = client.getQueryData(queryKeys.posts.list({}))
+ expect(list?.[0]?.likeCount).toBe(3)
+ })
+ })
+
+ it("rolls back the feed's cached copy too, alongside the detail cache", async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi
+ .fn()
+ .mockResolvedValue(
+ new Response(JSON.stringify({ error: { message: 'nope' } }), { status: 500 }),
+ ),
+ )
+ const { client, wrapper } = makeWrapper()
+ const { result } = renderHook(() => useLikePost('s'), { wrapper })
+ result.current.mutate()
+ await waitFor(() => expect(result.current.isError).toBe(true))
+ const list = client.getQueryData(queryKeys.posts.list({}))
+ expect(list?.[0]?.likeCount).toBe(2)
+ })
})
diff --git a/apps/client/src/hooks/use-likes.ts b/apps/client/src/hooks/use-likes.ts
index 7d5655c2..5d2fa12b 100644
--- a/apps/client/src/hooks/use-likes.ts
+++ b/apps/client/src/hooks/use-likes.ts
@@ -7,6 +7,12 @@ import type { Post } from '../api/posts.js'
* Optimistic, unlike `useDeletePost` — a like is cheap and reversible, so the
* count moves before the server answers and `onError` puts it back. The
* rollback is the point: the failure path is tested, not just the happy one.
+ *
+ * Patches both caches that hold a likeCount for this post — the detail query
+ * AND every cached feed variant under `posts.lists` — because they are two
+ * independent copies of the same number. Bumping only the detail cache left
+ * the feed showing the pre-like count until a hard refresh, since the list's
+ * 5-minute staleTime meant navigating back never triggered a refetch.
*/
export function useLikePost(slug: string) {
const queryClient = useQueryClient()
@@ -15,19 +21,34 @@ export function useLikePost(slug: string) {
mutationFn: () => postsApi.like(slug),
onMutate: async () => {
// An in-flight refetch would otherwise land after this and clobber it.
- await queryClient.cancelQueries({ queryKey: queryKeys.posts.detail(slug) })
- const previous = queryClient.getQueryData(queryKeys.posts.detail(slug))
- if (previous) {
+ await queryClient.cancelQueries({ queryKey: queryKeys.posts.all })
+
+ const previousDetail = queryClient.getQueryData(queryKeys.posts.detail(slug))
+ if (previousDetail) {
queryClient.setQueryData(queryKeys.posts.detail(slug), {
- ...previous,
- likeCount: previous.likeCount + 1,
+ ...previousDetail,
+ likeCount: previousDetail.likeCount + 1,
})
}
- return { previous }
+
+ // Snapshotted BEFORE mutating: setQueriesData's return value is the data
+ // AFTER the updater runs, not the prior value, so it cannot double as the
+ // rollback snapshot — this must be captured separately, first.
+ const previousLists = queryClient.getQueriesData({ queryKey: queryKeys.posts.lists })
+ queryClient.setQueriesData({ queryKey: queryKeys.posts.lists }, (old) =>
+ old?.map((post) => (post.slug === slug ? { ...post, likeCount: post.likeCount + 1 } : post)),
+ )
+
+ return { previousDetail, previousLists }
},
onError: (_err, _vars, context) => {
- if (context?.previous) queryClient.setQueryData(queryKeys.posts.detail(slug), context.previous)
+ if (context?.previousDetail) {
+ queryClient.setQueryData(queryKeys.posts.detail(slug), context.previousDetail)
+ }
+ context?.previousLists?.forEach(([key, data]) => queryClient.setQueryData(key, data))
},
- onSettled: () => queryClient.invalidateQueries({ queryKey: queryKeys.posts.detail(slug) }),
+ // Every posts query, not just this detail — the feed's cached lists carry
+ // their own copy of likeCount and must catch up too.
+ onSettled: () => queryClient.invalidateQueries({ queryKey: queryKeys.posts.all }),
})
}
diff --git a/apps/client/src/lib/query-client.ts b/apps/client/src/lib/query-client.ts
index 47c8ecad..28fba036 100644
--- a/apps/client/src/lib/query-client.ts
+++ b/apps/client/src/lib/query-client.ts
@@ -20,6 +20,13 @@ export const queryKeys = {
* they must all still be dropped together when a post changes.
*/
all: ['posts'] as const,
+ /**
+ * Match target for every cached feed variant regardless of filters, never
+ * fetched directly — same idea as `all`, one level narrower. Lets a
+ * mutation patch every list the feed might currently have cached without
+ * also touching detail queries, which store a single Post, not an array.
+ */
+ lists: ['posts', 'list'] as const,
list: (params: PostListParams = {}) => ['posts', 'list', params] as const,
/**
* Namespaced under 'detail' rather than sitting directly on the slug: with