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