Skip to content

Commit 1cc2274

Browse files
committed
fix(shop): concurrent optimistic cart mutations no longer flicker
When two cart mutations fired in quick succession (e.g. delete item A, then delete item B before A settles), A's onSuccess would overwrite the cache with the server response — which still contained B — undoing B's optimistic removal and making it flash back for a frame. Root cause: each mutation's onSuccess called setQueryData with the server response, clobbering any other in-flight optimistic state. Fix: - Removed onSuccess/setQueryData from update/remove/add mutations. Each onMutate already layers its change onto the current (possibly already-optimistic) cache, which is the right state for the user to see. - Added a shared CART_MUTATION_KEY across all cart-mutating hooks. - settleWhenIdle() checks isMutating({ mutationKey }) — only when the count is 0 (meaning the current mutation is the last to settle) does it invalidate the query, triggering a single background refetch that reconciles all accumulated changes with server truth at once. - Discount apply keeps onSuccess since it has no optimistic state (it only adds information, never conflicts with other in-flight ops).
1 parent 7ff5633 commit 1cc2274

File tree

1 file changed

+41
-33
lines changed

1 file changed

+41
-33
lines changed

src/hooks/useCart.ts

Lines changed: 41 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,32 @@ import type { CartDetail } from '~/utils/shopify-queries'
1818
*/
1919
export const CART_QUERY_KEY = ['shopify', 'cart'] as const
2020

21+
/**
22+
* Mutation key shared across all cart-mutating hooks. Used by
23+
* `settleWhenIdle` to determine whether other cart mutations are still
24+
* in flight before triggering a background refetch.
25+
*/
26+
const CART_MUTATION_KEY = ['shopify', 'cart', 'mutate'] as const
27+
28+
/**
29+
* Only invalidate (refetch from server) when no other cart mutations are
30+
* in flight. This prevents a settled mutation's refetch from overwriting
31+
* another mutation's optimistic state with stale server data.
32+
*
33+
* Each `onMutate` reads the *current* cache (which may already reflect
34+
* earlier optimistic writes) and layers its own change on top. When the
35+
* last mutation settles, the refetch reconciles everything with the
36+
* server's final truth.
37+
*/
38+
function settleWhenIdle(qc: ReturnType<typeof useQueryClient>) {
39+
// isMutating counts mutations that haven't settled yet. At the time
40+
// onSettled fires, the *current* mutation has already decremented, so
41+
// 0 means "I was the last one."
42+
if (qc.isMutating({ mutationKey: CART_MUTATION_KEY }) === 0) {
43+
qc.invalidateQueries({ queryKey: CART_QUERY_KEY })
44+
}
45+
}
46+
2147
/**
2248
* Read the current cart. Data is loader-seeded on shop routes, so there is
2349
* no hydration gap — components that call this render with real data on the
@@ -45,15 +71,12 @@ export function useAddToCart() {
4571
const qc = useQueryClient()
4672

4773
return useMutation({
74+
mutationKey: CART_MUTATION_KEY,
4875
mutationFn: (input: { variantId: string; quantity?: number }) =>
4976
addToCart({
5077
data: { variantId: input.variantId, quantity: input.quantity ?? 1 },
5178
}),
5279

53-
// Bump totalQuantity immediately so the navbar badge moves in the same
54-
// frame the user clicks. We can't optimistically render new line items
55-
// without the full product snapshot, which callers don't have here —
56-
// the refetch on settle fills that in.
5780
onMutate: async (input) => {
5881
const quantity = input.quantity ?? 1
5982
await qc.cancelQueries({ queryKey: CART_QUERY_KEY })
@@ -72,19 +95,18 @@ export function useAddToCart() {
7295
qc.setQueryData(CART_QUERY_KEY, ctx.previous)
7396
},
7497

75-
onSuccess: (cart) => {
76-
qc.setQueryData(CART_QUERY_KEY, cart)
77-
},
98+
// No onSuccess — don't overwrite the cache with the server response
99+
// while other mutations may be in flight. settleWhenIdle will refetch
100+
// from the server once all mutations have landed.
78101

79-
onSettled: () => {
80-
qc.invalidateQueries({ queryKey: CART_QUERY_KEY })
81-
},
102+
onSettled: () => settleWhenIdle(qc),
82103
})
83104
}
84105

85106
export function useUpdateCartLine() {
86107
const qc = useQueryClient()
87108
return useMutation({
109+
mutationKey: CART_MUTATION_KEY,
88110
mutationFn: (input: { lineId: string; quantity: number }) =>
89111
updateCartLine({ data: input }),
90112

@@ -112,19 +134,14 @@ export function useUpdateCartLine() {
112134
qc.setQueryData(CART_QUERY_KEY, ctx.previous)
113135
},
114136

115-
onSuccess: (cart) => {
116-
qc.setQueryData(CART_QUERY_KEY, cart)
117-
},
118-
119-
onSettled: () => {
120-
qc.invalidateQueries({ queryKey: CART_QUERY_KEY })
121-
},
137+
onSettled: () => settleWhenIdle(qc),
122138
})
123139
}
124140

125141
export function useRemoveCartLine() {
126142
const qc = useQueryClient()
127143
return useMutation({
144+
mutationKey: CART_MUTATION_KEY,
128145
mutationFn: (input: { lineId: string }) => removeCartLine({ data: input }),
129146

130147
onMutate: async (input) => {
@@ -149,33 +166,29 @@ export function useRemoveCartLine() {
149166
qc.setQueryData(CART_QUERY_KEY, ctx.previous)
150167
},
151168

152-
onSuccess: (cart) => {
153-
qc.setQueryData(CART_QUERY_KEY, cart)
154-
},
155-
156-
onSettled: () => {
157-
qc.invalidateQueries({ queryKey: CART_QUERY_KEY })
158-
},
169+
onSettled: () => settleWhenIdle(qc),
159170
})
160171
}
161172

162173
export function useApplyDiscountCode() {
163174
const qc = useQueryClient()
164175
return useMutation({
176+
mutationKey: CART_MUTATION_KEY,
165177
mutationFn: (input: { code: string }) =>
166178
applyDiscountCode({ data: { code: input.code } }),
167179
onSuccess: (cart) => {
180+
// Discount apply has no optimistic state, so setting server
181+
// response here is safe — it only adds information.
168182
qc.setQueryData(CART_QUERY_KEY, cart)
169183
},
170-
onSettled: () => {
171-
qc.invalidateQueries({ queryKey: CART_QUERY_KEY })
172-
},
184+
onSettled: () => settleWhenIdle(qc),
173185
})
174186
}
175187

176188
export function useRemoveDiscountCode() {
177189
const qc = useQueryClient()
178190
return useMutation({
191+
mutationKey: CART_MUTATION_KEY,
179192
mutationFn: () => removeDiscountCode(),
180193
onMutate: async () => {
181194
await qc.cancelQueries({ queryKey: CART_QUERY_KEY })
@@ -192,11 +205,6 @@ export function useRemoveDiscountCode() {
192205
if (ctx?.previous !== undefined)
193206
qc.setQueryData(CART_QUERY_KEY, ctx.previous)
194207
},
195-
onSuccess: (cart) => {
196-
qc.setQueryData(CART_QUERY_KEY, cart)
197-
},
198-
onSettled: () => {
199-
qc.invalidateQueries({ queryKey: CART_QUERY_KEY })
200-
},
208+
onSettled: () => settleWhenIdle(qc),
201209
})
202210
}

0 commit comments

Comments
 (0)