Skip to content

Commit c0b11f5

Browse files
authored
Merge pull request #16 from deco-sites/jonasjesus/deco-5278-cart-com-optimistic-updates
feat(cart): optimistic updates for quantity change and item removal (DECO-5278)
2 parents e3635ba + 6dbda10 commit c0b11f5

3 files changed

Lines changed: 80 additions & 8 deletions

File tree

src/components/header/Bag.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,17 @@
1+
import { useMutationState } from "@tanstack/react-query";
12
import { MINICART_DRAWER_ID } from "../../constants";
23
import Icon from "../ui/Icon";
34
import { useCart } from "../../platform/cart";
45

56
export default function Bag() {
67
const { cart } = useCart();
78
const count = cart.items.length;
9+
// Global "cart busy" indicator: any in-flight cart mutation (add/update/
10+
// remove) from anywhere in the tree, read via useMutationState by the
11+
// ["cart", …] mutationKey — no prop drilling.
12+
const busy = useMutationState({
13+
filters: { mutationKey: ["cart"], status: "pending" },
14+
}).length > 0;
815
return (
916
<label
1017
className="indicator"
@@ -17,7 +24,9 @@ export default function Bag() {
1724
</span>
1825
)}
1926
<span className="btn btn-square btn-sm btn-ghost no-animation">
20-
<Icon id="shopping_bag" />
27+
{busy
28+
? <span className="loading loading-spinner loading-xs" />
29+
: <Icon id="shopping_bag" />}
2130
</span>
2231
</label>
2332
);

src/components/minicart/Minicart.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,19 @@ import {
1313

1414
function QuantityStepper({ item }: { item: CartItem }) {
1515
const update = useUpdateCartItem();
16-
const pending = update.isPending && update.variables?.lineId === item.lineId;
1716
const set = (quantity: number) =>
1817
update.mutate({ lineId: item.lineId, quantity: Math.max(1, quantity) });
18+
// No `pending` freeze: the quantity updates optimistically on click and the
19+
// "cart" mutation scope serializes the requests, so rapid clicks stay
20+
// consistent and the buttons remain interactive. Only the lower bound is
21+
// disabled.
1922
return (
2023
<div className="join border border-base-200 rounded">
2124
<button
2225
type="button"
2326
className="join-item btn btn-ghost btn-sm no-animation"
2427
aria-label="Decrease quantity"
25-
disabled={pending || item.quantity <= 1}
28+
disabled={item.quantity <= 1}
2629
onClick={() => set(item.quantity - 1)}
2730
>
2831
-
@@ -34,7 +37,6 @@ function QuantityStepper({ item }: { item: CartItem }) {
3437
type="button"
3538
className="join-item btn btn-ghost btn-sm no-animation"
3639
aria-label="Increase quantity"
37-
disabled={pending}
3840
onClick={() => set(item.quantity + 1)}
3941
>
4042
+

src/platform/cart/cart.hooks.ts

Lines changed: 65 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,54 @@
1-
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
1+
import { type QueryClient, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
22
import {
33
addItemServerFn,
44
getCartServerFn,
55
removeItemServerFn,
66
updateItemQuantityServerFn,
77
} from "./cart.actions";
8-
import { EMPTY_CART, type CartState } from "./cart.types";
8+
import { type CartItem, EMPTY_CART, type CartState } from "./cart.types";
99

1010
export const CART_QUERY_KEY = ["cart"] as const;
1111

12+
interface OptimisticContext {
13+
prev: CartState;
14+
}
15+
16+
/**
17+
* Applies new line items to a cart, re-deriving only the fields we can compute
18+
* client-side: `subtotal` (Σ price × qty) and `totalQuantity`. `total` is left
19+
* untouched on purpose — it may include discounts/shipping/tax we don't know
20+
* here — and is reconciled from the server cart in `onSuccess`.
21+
*/
22+
function applyOptimisticItems(cart: CartState, items: CartItem[]): CartState {
23+
return {
24+
...cart,
25+
items,
26+
totalQuantity: items.reduce((n, i) => n + i.quantity, 0),
27+
subtotal: {
28+
amount: items.reduce((sum, i) => sum + i.price.amount * i.quantity, 0),
29+
currencyCode: cart.subtotal.currencyCode,
30+
},
31+
};
32+
}
33+
34+
/**
35+
* Shared optimistic-mutation plumbing: cancel in-flight cart fetches, snapshot
36+
* the current cart for rollback, and write the transformed items to the cache.
37+
*/
38+
async function optimisticCartUpdate(
39+
qc: QueryClient,
40+
transform: (items: CartItem[]) => CartItem[],
41+
): Promise<OptimisticContext> {
42+
await qc.cancelQueries({ queryKey: CART_QUERY_KEY });
43+
const prev = qc.getQueryData<CartState>(CART_QUERY_KEY) ?? EMPTY_CART;
44+
qc.setQueryData(CART_QUERY_KEY, applyOptimisticItems(prev, transform(prev.items)));
45+
return { prev };
46+
}
47+
48+
function rollbackCart(qc: QueryClient, ctx: OptimisticContext | undefined) {
49+
if (ctx?.prev) qc.setQueryData(CART_QUERY_KEY, ctx.prev);
50+
}
51+
1252
export function useCart() {
1353
const query = useQuery({
1454
queryKey: CART_QUERY_KEY,
@@ -27,8 +67,18 @@ export function useCart() {
2767
export function useAddToCart() {
2868
const qc = useQueryClient();
2969
return useMutation({
70+
// Serialize all cart mutations (same scope id) so rapid actions run in
71+
// order — the server sets absolute quantities, so out-of-order responses
72+
// would otherwise clobber the cache. mutationKey lets useMutationState
73+
// surface a global "cart busy" indicator.
74+
scope: { id: "cart" },
75+
mutationKey: ["cart", "add"],
3076
mutationFn: (input: { merchandiseId: string; quantity?: number }) =>
3177
addItemServerFn({ data: input }),
78+
// NOTE(DECO-5278): optimistic add is deferred — building an optimistic
79+
// line needs a product snapshot (title/image/price). It should come from a
80+
// neutral `productToCartItem` mapping over commerce types, tracked as a
81+
// remaining sub-item. Add already shows button feedback while in flight.
3282
onSuccess: (cart: CartState) => {
3383
qc.setQueryData(CART_QUERY_KEY, cart);
3484
},
@@ -38,8 +88,15 @@ export function useAddToCart() {
3888
export function useUpdateCartItem() {
3989
const qc = useQueryClient();
4090
return useMutation({
91+
scope: { id: "cart" },
92+
mutationKey: ["cart", "update"],
4193
mutationFn: (input: { lineId: string; quantity: number }) =>
4294
updateItemQuantityServerFn({ data: input }),
95+
onMutate: ({ lineId, quantity }) =>
96+
optimisticCartUpdate(qc, (items) =>
97+
items.map((i) => (i.lineId === lineId ? { ...i, quantity: Math.max(1, quantity) } : i)),
98+
),
99+
onError: (_err, _input, ctx) => rollbackCart(qc, ctx),
43100
onSuccess: (cart: CartState) => {
44101
qc.setQueryData(CART_QUERY_KEY, cart);
45102
},
@@ -49,8 +106,12 @@ export function useUpdateCartItem() {
49106
export function useRemoveCartItem() {
50107
const qc = useQueryClient();
51108
return useMutation({
52-
mutationFn: (input: { lineId: string }) =>
53-
removeItemServerFn({ data: input }),
109+
scope: { id: "cart" },
110+
mutationKey: ["cart", "remove"],
111+
mutationFn: (input: { lineId: string }) => removeItemServerFn({ data: input }),
112+
onMutate: ({ lineId }) =>
113+
optimisticCartUpdate(qc, (items) => items.filter((i) => i.lineId !== lineId)),
114+
onError: (_err, _input, ctx) => rollbackCart(qc, ctx),
54115
onSuccess: (cart: CartState) => {
55116
qc.setQueryData(CART_QUERY_KEY, cart);
56117
},

0 commit comments

Comments
 (0)