Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
35 changes: 35 additions & 0 deletions apps/client/src/hooks/use-likes.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => (
<QueryClientProvider client={client}>{children}</QueryClientProvider>
)
Expand Down Expand Up @@ -57,4 +61,35 @@ describe('useLikePost', () => {
await waitFor(() => expect(result.current.isError).toBe(true))
expect(client.getQueryData<Post>(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<Post[]>(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<Post[]>(queryKeys.posts.list({}))
expect(list?.[0]?.likeCount).toBe(2)
})
})
37 changes: 29 additions & 8 deletions apps/client/src/hooks/use-likes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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<Post>(queryKeys.posts.detail(slug))
if (previous) {
await queryClient.cancelQueries({ queryKey: queryKeys.posts.all })

const previousDetail = queryClient.getQueryData<Post>(queryKeys.posts.detail(slug))
if (previousDetail) {
queryClient.setQueryData<Post>(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<Post[]>({ queryKey: queryKeys.posts.lists })
queryClient.setQueriesData<Post[]>({ 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 }),
})
}
7 changes: 7 additions & 0 deletions apps/client/src/lib/query-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading