|
| 1 | +# Optimistic Cart — Custom Zustand Cart Sync — Implementation |
| 2 | + |
| 3 | +> This documents the architecture **as shipped** in PR #375. It is a |
| 4 | +> reverse-documented spec (the code merged before this folder existed), so it |
| 5 | +> describes the final state rather than a step-by-step build order. |
| 6 | +
|
| 7 | +**Goal:** Eliminate cart data inconsistency (stale quantities, drop-back |
| 8 | +flashes, phantom `qty:0`, `$numCartLines` errors) by replacing Hydrogen's |
| 9 | +`useOptimisticCart` with a custom single-source-of-truth cart hook. |
| 10 | + |
| 11 | +**Tech Stack:** React, react-router (`useFetchers`, `useRouteLoaderData`), |
| 12 | +zustand, `@shopify/hydrogen` (`CartForm` only — `useOptimisticCart` removed). |
| 13 | + |
| 14 | +--- |
| 15 | + |
| 16 | +## Architecture |
| 17 | + |
| 18 | +A custom cart pipeline in `app/components/cart/store.ts` replaces |
| 19 | +`useOptimisticCart`. Every cart consumer now reads from a single `useCart()` |
| 20 | +hook instead of subscribing to the deferred root-loader promise directly. |
| 21 | + |
| 22 | +``` |
| 23 | +root loader (deferred cart.get()) |
| 24 | + │ |
| 25 | + ▼ |
| 26 | + <CartStoreSync/> ──(updatedAt gate)──► zustand: serverCart |
| 27 | + │ |
| 28 | + fetcher responses ──► useCartFetcherSync ───────►│ (+ freshestFetcherCartRef) |
| 29 | + ▼ |
| 30 | + useCart() ── picks freshest baseline |
| 31 | + ── filters removedLineIds |
| 32 | + ── applyOptimisticMutations(pending only) |
| 33 | + ▼ |
| 34 | + CartDrawer badge/title · CartMain · CartRoute · CartSummary |
| 35 | +``` |
| 36 | + |
| 37 | +### Core pieces (`store.ts`) |
| 38 | + |
| 39 | +- **`useCartStore`** (zustand) — renamed from `useCartDrawerStore`. Adds |
| 40 | + `serverCart: CartApiQueryFragment | null` alongside the existing |
| 41 | + `isOpen`/`open`/`close`/`toggle` drawer state. |
| 42 | +- **`freshestFetcherCartRef`** — module-level ref holding the freshest |
| 43 | + fetcher-sourced cart + its `updatedAt`. Survives React Router's synchronous |
| 44 | + fetcher cleanup. |
| 45 | +- **`removedLineIds`** — module-level `Set<string>` of optimistically removed |
| 46 | + line IDs. Filtered from the baseline until the server cart confirms the lines |
| 47 | + are gone. Needed because RR deletes fetchers from unmounted remove buttons |
| 48 | + before their response is visible via `useFetchers()`. |
| 49 | +- **`applyOptimisticMutations(baseline, fetchers)`** — applies **only pending** |
| 50 | + (`state === "submitting"` with `formData`) fetchers. This is the key fix for |
| 51 | + double-counting: idle fetchers are never re-applied. Handles |
| 52 | + `LinesAdd` (merge into existing line or unshift synthetic |
| 53 | + `optimistic-<uuid>` line), `LinesRemove` (splice + tombstone), `LinesUpdate` |
| 54 | + (set qty, splice on 0). Recomputes `totalQuantity`; sets `isOptimistic`. |
| 55 | +- **`useCartFetcherSync(fetcher)`** — syncs a *singular* fetcher's cart into |
| 56 | + zustand **during render** (via `queueMicrotask` to avoid setState-in-render |
| 57 | + warnings), gated by `updatedAt` so older data never clobbers newer. Singular |
| 58 | + `useFetcher().data` survives cleanup where plural `useFetchers()` does not. |
| 59 | +- **`useCart()`** — single source of truth. Picks the freshest baseline across |
| 60 | + zustand `serverCart`, `freshestFetcherCartRef`, and a same-render scan of |
| 61 | + idle `useFetchers()`; filters tombstoned `removedLineIds` (clearing confirmed |
| 62 | + removals); then overlays pending optimistic mutations. |
| 63 | +- **`CartStoreSync()`** — component mounted in `root.tsx`. Resolves the deferred |
| 64 | + root-loader cart promise and writes it to `serverCart` **only if** its |
| 65 | + `updatedAt` is `>=` the current one — skipping the stale-overwrite flaw. |
| 66 | + |
| 67 | +### GraphQL fix |
| 68 | + |
| 69 | +- `CART_MUTATION_FRAGMENT` (`app/graphql/fragments.ts`) replaces |
| 70 | + `lines(first: $numCartLines)` with a hardcoded `lines(first: 250)`, removing |
| 71 | + the undeclared-variable error on mutations. |
| 72 | +- `app/.server/context.ts` wires the cart handler to use |
| 73 | + `CART_MUTATION_FRAGMENT`. |
| 74 | + |
| 75 | +### Consumer simplification |
| 76 | + |
| 77 | +- `CartDrawer` — dropped the `Suspense` / `Await` / nested `CartDrawerContent`; |
| 78 | + reads `useCart()` directly for badge, title, and `CartMain`. |
| 79 | +- `CartRoute` (`cart-page.tsx`) — `useCart()` replaces `useOptimisticCart`. |
| 80 | +- `cart-main.tsx` — type adjustment for the `useCart()` return shape. |
| 81 | +- `cart-line-item.tsx`, `add-to-cart-button.tsx`, `blog-post.tsx` — import |
| 82 | + rename `useCartDrawerStore` → `useCartStore`; dead code removed. |
| 83 | +- `cart-line-qty-adjust.tsx`, `cart-summary.tsx`, `cart-summary-actions.tsx` — |
| 84 | + `isOptimistic` propagation / skeleton wiring against the new cart shape. |
| 85 | + |
| 86 | +--- |
| 87 | + |
| 88 | +## Files Touched (PR #375) |
| 89 | + |
| 90 | +| File | Change | |
| 91 | +|------|--------| |
| 92 | +| `app/components/cart/store.ts` | New custom cart sync (zustand + render-time fetcher sync, tombstones, optimistic mutations) — core of the change | |
| 93 | +| `app/graphql/fragments.ts` | `$numCartLines` → hardcoded `250` in `CART_MUTATION_FRAGMENT` | |
| 94 | +| `app/.server/context.ts` | Cart handler uses `CART_MUTATION_FRAGMENT` | |
| 95 | +| `app/root.tsx` | Mounts `<CartStoreSync />` | |
| 96 | +| `app/components/cart/cart-drawer.tsx` | `useCart()` replaces `Suspense`/`Await`/`useOptimisticCart` | |
| 97 | +| `app/routes/cart/cart-page.tsx` | `useCart()` replaces `useOptimisticCart` | |
| 98 | +| `app/components/cart/cart-main.tsx` | Type adjustment for `useCart()` shape | |
| 99 | +| `app/components/cart/cart-line-item.tsx` | Import rename, dead code removed | |
| 100 | +| `app/components/cart/cart-line-qty-adjust.tsx` | `isOptimistic` wiring against new shape | |
| 101 | +| `app/components/cart/cart-summary.tsx` | Skeleton / `isOptimistic` propagation | |
| 102 | +| `app/components/cart/cart-summary-actions.tsx` | Skeleton / `isOptimistic` propagation | |
| 103 | +| `app/components/product/add-to-cart-button.tsx` | Import rename `useCartDrawerStore` → `useCartStore` | |
| 104 | +| `app/sections/blog-post.tsx` | Import rename `useCartDrawerStore` → `useCartStore` | |
| 105 | + |
| 106 | +No test files (Shopify Hydrogen template — manual QA). |
| 107 | + |
| 108 | +--- |
| 109 | + |
| 110 | +## Verification |
| 111 | + |
| 112 | +Manual QA per PR #375: rapid consecutive add-to-cart clicks update quantity |
| 113 | +correctly with no drop-back flash and no phantom `qty:0`; add/remove/quantity |
| 114 | +mutations no longer flash stale data; add-to-cart no longer throws the |
| 115 | +`$numCartLines` GraphQL error. |
| 116 | + |
| 117 | +--- |
| 118 | + |
| 119 | +## Follow-up fixes (post-merge) |
| 120 | + |
| 121 | +### 2026-05-18 — Stale checkout total on line removal |
| 122 | + |
| 123 | +**Symptom:** removing a line item via the trash button did not update the |
| 124 | +checkout-button total in either the drawer or the page; it only refreshed on a |
| 125 | +subsequent add / quantity change. |
| 126 | + |
| 127 | +**Cause:** the remove `CartForm` used an anonymous fetcher. The component |
| 128 | +syncing its response (`ItemRemoveButtonInner`) unmounts the moment the line is |
| 129 | +optimistically spliced out, so React Router discards the fetcher and the |
| 130 | +authoritative post-remove cart (with correct `cost`) is lost. The `store.ts` |
| 131 | +fallbacks (`applyOptimisticMutations` and the `removedLineIds` tombstone |
| 132 | +filter) recompute `totalQuantity` but never `cost`. |
| 133 | + |
| 134 | +**Fix (no `store.ts` change):** the keyed-fetcher pattern already proven for |
| 135 | +discount-code / gift-card removal was extended to line removal. |
| 136 | + |
| 137 | +| File | Change | |
| 138 | +|------|--------| |
| 139 | +| `app/components/cart/cart-line-item.tsx` | `ItemRemoveButton` `CartForm` gets stable `fetcherKey="cart-line-remove"` | |
| 140 | +| `app/components/cart/cart-summary.tsx` | `useFetcher({ key: "cart-line-remove" })` + `useCartFetcherSync(...)` to capture the post-remove cart from the always-mounted summary; added to `isCartUpdating` so the total shows a skeleton during the transition | |
| 141 | + |
| 142 | +The server-computed cost is now used (correct with cart-level |
| 143 | +discounts/taxes — no client-side cost recomputation). The tombstone / |
| 144 | +`useCart()` design was unchanged; the gap was purely a missing keyed fetcher. |
0 commit comments