Skip to content

Commit 0d0fdb0

Browse files
committed
fix(shop): cart panel slide animation + concurrent delete race
Animation: replaced nonexistent tailwindcss-animate classes with real CSS keyframes in app.css. Panel now slides in from the right with a fade, and slides out on close. Concurrent deletes: replaced isMutating() check with an explicit module-level counter (cartMutationsInFlight). The isMutating semantics at onSettled time are ambiguous across React Query versions — the counter is unambiguous: increment in onMutate, decrement in onSettled, invalidate at zero. This prevents a settled mutation's refetch from bringing back an item that was optimistically removed by a concurrent mutation still in flight.
1 parent b500aa1 commit 0d0fdb0

File tree

3 files changed

+83
-23
lines changed

3 files changed

+83
-23
lines changed

src/components/shop/CartDrawer.tsx

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -26,24 +26,16 @@ export function CartDrawer({ open, onOpenChange }: CartDrawerProps) {
2626
return (
2727
<Dialog.Root open={open} onOpenChange={onOpenChange}>
2828
<Dialog.Portal>
29-
<Dialog.Overlay
30-
className={twMerge(
31-
'fixed inset-0 z-[100] bg-black/20',
32-
'data-[state=open]:animate-in data-[state=open]:fade-in-0',
33-
'data-[state=closed]:animate-out data-[state=closed]:fade-out-0',
34-
)}
35-
/>
29+
<Dialog.Overlay className="cart-overlay fixed inset-0 z-[100] bg-black/20" />
3630
<Dialog.Content
3731
className={twMerge(
32+
'cart-panel',
3833
'fixed right-4 top-[calc(var(--navbar-height,56px)+0.5rem)] z-[100]',
3934
'w-[calc(100vw-2rem)] sm:w-[24rem]',
4035
'max-h-[calc(100dvh-var(--navbar-height,56px)-1rem)]',
4136
'flex flex-col',
4237
'rounded-xl shadow-2xl',
4338
'bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800',
44-
'data-[state=open]:animate-in data-[state=open]:slide-in-from-right-5 data-[state=open]:fade-in-0',
45-
'data-[state=closed]:animate-out data-[state=closed]:slide-out-to-right-5 data-[state=closed]:fade-out-0',
46-
'duration-200',
4739
)}
4840
aria-describedby={undefined}
4941
>

src/hooks/useCart.ts

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -26,25 +26,31 @@ export const CART_QUERY_KEY = ['shopify', 'cart'] as const
2626
const CART_MUTATION_KEY = ['shopify', 'cart', 'mutate'] as const
2727

2828
/**
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.
29+
* Explicit in-flight counter. We don't rely on `queryClient.isMutating()`
30+
* because its exact semantics at `onSettled` time (does it still count the
31+
* current mutation?) vary across React Query versions and are under-documented.
32+
* A module-level counter is unambiguous: increment in onMutate, decrement in
33+
* onSettled, invalidate when the count hits zero.
34+
*/
35+
let cartMutationsInFlight = 0
36+
37+
function trackMutationStart() {
38+
cartMutationsInFlight++
39+
}
40+
41+
/**
42+
* Call from every cart mutation's `onSettled`. Decrements the in-flight
43+
* counter, and when the last mutation settles, triggers a single background
44+
* refetch to reconcile all accumulated optimistic changes with server truth.
3745
*
3846
* Returns the invalidation promise so the mutation stays in `isPending`
39-
* until the background refetch completes (per TkDodo's recommendation).
47+
* until the refetch completes.
4048
*
4149
* @see https://tkdodo.eu/blog/concurrent-optimistic-updates-in-react-query
4250
*/
4351
function settleWhenIdle(qc: ReturnType<typeof useQueryClient>) {
44-
// isMutating counts mutations that haven't settled yet. At the time
45-
// onSettled fires, the *current* mutation is still counted, so
46-
// 1 means "I'm the last one in flight."
47-
if (qc.isMutating({ mutationKey: CART_MUTATION_KEY }) === 1) {
52+
cartMutationsInFlight = Math.max(0, cartMutationsInFlight - 1)
53+
if (cartMutationsInFlight === 0) {
4854
return qc.invalidateQueries({ queryKey: CART_QUERY_KEY })
4955
}
5056
}
@@ -108,6 +114,7 @@ export function useAddToCart() {
108114
}),
109115

110116
onMutate: async (input) => {
117+
trackMutationStart()
111118
const quantity = input.quantity ?? 1
112119
await qc.cancelQueries({ queryKey: CART_QUERY_KEY })
113120
const previous = qc.getQueryData<CartDetail | null>(CART_QUERY_KEY)
@@ -193,6 +200,7 @@ export function useUpdateCartLine() {
193200
updateCartLine({ data: input }),
194201

195202
onMutate: async (input) => {
203+
trackMutationStart()
196204
await qc.cancelQueries({ queryKey: CART_QUERY_KEY })
197205
const previous = qc.getQueryData<CartDetail | null>(CART_QUERY_KEY)
198206
if (previous) {
@@ -227,6 +235,7 @@ export function useRemoveCartLine() {
227235
mutationFn: (input: { lineId: string }) => removeCartLine({ data: input }),
228236

229237
onMutate: async (input) => {
238+
trackMutationStart()
230239
await qc.cancelQueries({ queryKey: CART_QUERY_KEY })
231240
const previous = qc.getQueryData<CartDetail | null>(CART_QUERY_KEY)
232241
if (previous) {
@@ -259,6 +268,7 @@ export function useApplyDiscountCode() {
259268
mutationFn: (input: { code: string }) =>
260269
applyDiscountCode({ data: { code: input.code } }),
261270
onMutate: async () => {
271+
trackMutationStart()
262272
await qc.cancelQueries({ queryKey: CART_QUERY_KEY })
263273
},
264274
onSuccess: (cart) => {
@@ -274,6 +284,7 @@ export function useRemoveDiscountCode() {
274284
mutationKey: CART_MUTATION_KEY,
275285
mutationFn: () => removeDiscountCode(),
276286
onMutate: async () => {
287+
trackMutationStart()
277288
await qc.cancelQueries({ queryKey: CART_QUERY_KEY })
278289
const previous = qc.getQueryData<CartDetail | null>(CART_QUERY_KEY)
279290
if (previous) {

src/styles/app.css

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1060,6 +1060,63 @@ mark {
10601060
animation: dropdown-out 100ms ease-in;
10611061
}
10621062

1063+
/* Cart panel slide-in from right */
1064+
@keyframes cart-panel-in {
1065+
from {
1066+
opacity: 0;
1067+
transform: translateX(1rem);
1068+
}
1069+
to {
1070+
opacity: 1;
1071+
transform: translateX(0);
1072+
}
1073+
}
1074+
1075+
@keyframes cart-panel-out {
1076+
from {
1077+
opacity: 1;
1078+
transform: translateX(0);
1079+
}
1080+
to {
1081+
opacity: 0;
1082+
transform: translateX(1rem);
1083+
}
1084+
}
1085+
1086+
@keyframes cart-overlay-in {
1087+
from {
1088+
opacity: 0;
1089+
}
1090+
to {
1091+
opacity: 1;
1092+
}
1093+
}
1094+
1095+
@keyframes cart-overlay-out {
1096+
from {
1097+
opacity: 1;
1098+
}
1099+
to {
1100+
opacity: 0;
1101+
}
1102+
}
1103+
1104+
.cart-panel[data-state='open'] {
1105+
animation: cart-panel-in 200ms ease-out;
1106+
}
1107+
1108+
.cart-panel[data-state='closed'] {
1109+
animation: cart-panel-out 150ms ease-in;
1110+
}
1111+
1112+
.cart-overlay[data-state='open'] {
1113+
animation: cart-overlay-in 200ms ease-out;
1114+
}
1115+
1116+
.cart-overlay[data-state='closed'] {
1117+
animation: cart-overlay-out 150ms ease-in;
1118+
}
1119+
10631120
/* Doc Feedback Styles */
10641121
.doc-feedback-block {
10651122
transition:

0 commit comments

Comments
 (0)