From 86dba54df39de7bb61d894f46f920988f753375e Mon Sep 17 00:00:00 2001 From: Yonatan Hen Date: Thu, 30 Jul 2026 19:02:24 +0300 Subject: [PATCH] fix(client): sync the feed's cached like count after liking a post MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Liking a post, then navigating back to the feed, showed the pre-like count until a hard refresh. useLikePost's optimistic update and invalidation only ever touched the post-detail query (queryKeys.posts.detail(slug)); the feed keeps its own separate cached copy of the same post under queryKeys.posts.list(params), and with a 5-minute staleTime, navigating back never triggered a refetch to pick up the real number. Adds queryKeys.posts.lists as a match target for every cached feed variant, alongside the existing queryKeys.posts.all — narrower than `all` so it can be used to patch list-shaped cache entries (Post[]) without also touching detail entries (a single Post), which would break if fed the same array-mapping updater. onMutate now patches both the detail cache and every cached list via setQueriesData, using getQueriesData first to snapshot the true prior state for rollback — setQueriesData's own return value is the data AFTER the updater runs, not before, so it cannot double as that snapshot. onSettled invalidates queryKeys.posts.all instead of just the one detail key, so every filtered feed variant refreshes too. Verified live: liked a post, navigated back via the nav link (no reload), and the feed showed the updated count immediately. --- apps/client/src/hooks/use-likes.test.tsx | 35 ++++++++++++++++++++++ apps/client/src/hooks/use-likes.ts | 37 +++++++++++++++++++----- apps/client/src/lib/query-client.ts | 7 +++++ 3 files changed, 71 insertions(+), 8 deletions(-) diff --git a/apps/client/src/hooks/use-likes.test.tsx b/apps/client/src/hooks/use-likes.test.tsx index dcf953be4..cf32f9af5 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 7d5655c2e..5d2fa12bc 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 47c8ecadc..28fba0365 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