fix: optimistic cart — custom zustand-based cart sync replacing Hydrogen's useOptimisticCart#375
Conversation
Root cause analysis and fix plan for three optimistic cart issues: stale badge/title, remove flicker, and slow total updates.
5-task plan covering: lift useOptimisticCart into CartDrawer, update CartMain prop types, add hook to cart page route, remove dual-mechanism flicker, and verify skeleton propagation.
Extract CartDrawerContent component that calls useOptimisticCart, ensuring badge count, drawer title, and cart items all share the same optimistic state. CartMain now receives optimistic cart from its parent.
Full-page cart view now has optimistic behavior matching the drawer.
useOptimisticCart already splices removed lines from the array, so the OptimisticInput + CSS display:none trick was redundant and caused a flicker when the fetcher completed.
- Remove dead `action` field from CartLineOptimisticData type - Replace JSON.stringify emptiness check with Object.keys().length - Condense stale comment to single-line workaround note - Fix import ordering in cart-drawer.tsx
…t sync Hydrogen's useOptimisticCart processes ALL fetchers with formData (including idle/completed), causing double-counting and phantom qty:0. The deferred root loader also overwrites fresh fetcher data with stale pre-mutation state. Custom implementation: - findFreshestFetcherCart: uses completed fetcher cart as fresh baseline - applyOptimisticMutations: only processes PENDING fetchers (prevents double-counting) - CartStoreSync: uses updatedAt timestamp comparison to skip stale root loader data - useCart: render-time fetcher sync via useEffect, single source of truth Also fixes $numCartLines variable error by hardcoding 250 in CART_MUTATION_FRAGMENT and moves spec docs to .weaverse/specs/.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
paul-phan
left a comment
There was a problem hiding this comment.
Code Review: Custom Zustand-Based Cart Sync
Commit: c69ee1ec — Replace Hydrogen useOptimisticCart with custom zustand-based cart store
Summary
This PR replaces Hydrogen's built-in useOptimisticCart with a custom implementation that solves real, confirmed bugs: stale deferred root loader data overwriting fresher mutation results, double-counting from Hydrogen's useOptimisticCart not filtering by fetcher state, and visual "drop-back" flashes between optimistic UI ending and server data arriving. The architecture — zustand store as single source of truth, render-time fetcher sync, timestamp-guarded root loader updates — is sound and well-motivated.
The unrelated XShareButton → TwitterShareButton fix in blog-post.tsx and the mutateFragment addition in context.ts are both correct.
Issues
1. findFreshestFetcherCart doesn't actually find the freshest — it finds the last in array order
app/components/cart/store.ts:31-45 · Confidence: 85%
function findFreshestFetcherCart(fetchers) {
let freshest = null;
for (const fetcher of fetchers) {
if (fetcher.state === "idle" && fetcher.data?.cart?.id && fetcher.data?.cart?.lines) {
freshest = fetcher.data.cart;
}
}
return freshest;
}The name says "freshest" but the logic returns whichever idle-with-cart-data fetcher appears last in the useFetchers() array. React Router doesn't guarantee ordering by completion time. If two mutations complete in the same render (e.g., rapid add-then-remove), this may pick the wrong one.
Since CartStoreSync already uses updatedAt timestamps for root loader data, consider comparing updatedAt here too for consistency:
if (fetcher.state === "idle" && fetcher.data?.cart?.id && fetcher.data?.cart?.lines) {
const candidate = fetcher.data.cart as CartApiQueryFragment;
if (!freshest || new Date(candidate.updatedAt).getTime() > new Date(freshest.updatedAt).getTime()) {
freshest = candidate;
}
}2. CART_MUTATION_FRAGMENT string replacement only targets first occurrence
app/graphql/fragments.ts:360-363 · Confidence: 80%
export const CART_MUTATION_FRAGMENT = CART_QUERY_FRAGMENT.replace(
"fragment CartApiQuery on Cart",
"fragment CartApiMutation on Cart",
).replace("$numCartLines", "250");String.replace() without /g flag only replaces the first match. Currently there's exactly one $numCartLines in the fragment, so this works. But if anyone adds a second reference to $numCartLines in CART_QUERY_FRAGMENT, the second occurrence silently survives as an undeclared variable — the exact bug this PR fixes.
Safer:
).replaceAll("$numCartLines", "250");A comment explaining why the variable substitution exists would help future maintainers.
3. structuredClone on every render with pending fetchers
app/components/cart/store.ts:56 · Confidence: 90% (correctness is fine, perf suggestion)
const cart = structuredClone(baseline) as CartWithOptimistic & { ... };applyOptimisticMutations runs on every render of any component using useCart() when there are pending fetchers. structuredClone deep-copies the entire cart object including all line items, images, cost objects, etc. Since only lines.nodes, totalQuantity, and isOptimistic are mutated, a shallow copy would be sufficient and measurably faster:
const nodes = [...baseline.lines.nodes];
const cart = {
...baseline,
lines: { ...baseline.lines, nodes },
totalQuantity: baseline.totalQuantity,
isOptimistic: false,
} as CartWithOptimistic & { lines: { nodes: OptimisticLineNode[] }; totalQuantity: number };Not blocking, but worth doing since this is a hot path.
4. Pre-existing: NoteUpdate can leave result uninitialized
app/routes/cart/cart-page.tsx:48-53 · Not part of this diff
case CartForm.ACTIONS.NoteUpdate: {
const cartNote = inputs.cartNote as string;
if (cartNote) {
result = await cart.updateNote(cartNote);
}
break;
}If cartNote is falsy (empty string to clear the note), result is never assigned. Line 98 handles it with result || {}, so no crash — but a "clear note" action silently does nothing. Pre-existing, noting for awareness.
Verdict
Approve with suggestions. The core architecture is correct and solves confirmed bugs that Hydrogen's built-in useOptimisticCart doesn't handle. Issues #1 and #2 are worth addressing before merge; #3 is optional cleanup.
There was a problem hiding this comment.
Pull request overview
Replaces Hydrogen’s useOptimisticCart usage with a custom zustand-backed cart synchronization layer to prevent cart UI inconsistencies during mutations, and fixes the cart mutation GraphQL fragment so mutations no longer reference an undeclared $numCartLines variable.
Changes:
- Added a zustand-based cart store +
useCart()hook that chooses a “freshest” baseline cart and applies only pending optimistic mutations. - Wired cart consumers (drawer + cart route + add-to-cart interactions) to use the new
useCart()/useCartStore()APIs. - Introduced a dedicated cart mutation fragment (
CART_MUTATION_FRAGMENT) that hardcodes cart line count to avoid$numCartLinesGraphQL errors.
Reviewed changes
Copilot reviewed 13 out of 13 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| app/components/cart/store.ts | New zustand cart state + fetcher/root-loader syncing + custom optimistic overlay logic |
| app/components/cart/cart-drawer.tsx | Removes <Suspense>/<Await> cart consumption; uses useCart() + useCartStore() |
| app/routes/cart/cart-page.tsx | Switches cart page to useCart() instead of loader cart |
| app/components/cart/cart-main.tsx | Updates prop typing to accept an optimistic/nullable cart and removes useOptimisticCart usage |
| app/components/cart/cart-line-item.tsx | Store import rename + simplifies optimistic-state handling and remove-button logic |
| app/components/product/add-to-cart-button.tsx | Store import rename (useCartDrawerStore → useCartStore) |
| app/graphql/fragments.ts | Adds CART_MUTATION_FRAGMENT by transforming the query fragment and replacing $numCartLines |
| app/.server/context.ts | Configures Hydrogen cart context to use both query and mutation fragments |
| app/root.tsx | Adds <CartStoreSync /> to sync deferred root cart into zustand |
| app/sections/blog-post.tsx | Replaces XShareButton with TwitterShareButton from react-share |
| .weaverse/specs/2026-04-13-optimistic-cart-split-design.md | New spec doc (currently not aligned with the implemented approach) |
| .weaverse/specs/2026-04-10-optimistic-cart-fix*.md | New spec/design docs related to optimistic cart work |
Comments suppressed due to low confidence (1)
app/routes/cart/cart-page.tsx:122
useCart()replaces the loader-providedcarthere, which means/cartcan initially render as empty (Cart (0)+ empty-state UI) untilCartStoreSync's effect resolves the deferred root cart promise. Since this route's loader already awaitscart.get(), consider using that loader cart as the immediate baseline (e.g., passoriginalCartintouseCart(originalCart)or seed the zustandserverCartfrom loader data) to avoid a flash of incorrect empty cart state.
export default function CartRoute() {
const { featuredProducts } = useLoaderData<typeof loader>();
const cart = useCart();
return (
<>
<Section width="fixed" verticalPadding="medium">
<h1 className="h3 mb-8 text-center md:mb-16">
Cart ({cart?.totalQuantity || 0})
</h1>
<CartMain layout="page" cart={cart} />
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
hta218
left a comment
There was a problem hiding this comment.
Sometimes it works, sometimes it's not, please see this video: https://www.loom.com/share/d97c8ea3e93c47fd9ec12beb0395d4ce
paul-phan
left a comment
There was a problem hiding this comment.
Code Review: Custom Zustand-Based Cart Sync
Architecture
The approach is sound. Hydrogen's useOptimisticCart has real design flaws (double-counting from idle fetchers, no stale root loader protection), and replacing it with a custom zustand-based system is the right call. The updatedAt timestamp comparison in CartStoreSync is an elegant solution to the stale root loader overwrite problem.
Critical: findFreshestFetcherCart via useFetchers() is unreliable
app/components/cart/store.ts:32-44 — findFreshestFetcherCart scans useFetchers() for idle fetchers with cart data. This has two issues:
-
React Router cleans up idle fetchers synchronously.
useFetchers()reads fromstate.fetchers— React Router deletes idle fetchers from this map on the same synchronous tick as completion. In practice, this function rarely finds completed fetchers, making the fetcher-to-zustand sync inuseCart()unreliable. -
Returns last in array order, not freshest. The function name implies timestamp comparison but simply picks whichever idle fetcher appears last in the list. If two fetchers complete in the same render cycle, the order is arbitrary.
Fix (already implemented locally, not yet committed): Replace findFreshestFetcherCart with useCartFetcherSync(fetcher) — a hook that takes a singular useFetcher() instance. The singular useFetcher() preserves fetcher.data via an internal fetcherData ref that survives cleanup, unlike the plural useFetchers() map. Each component that uses CartForm calls useCartFetcherSync(fetcher) to reliably sync its mutation response to zustand.
Severity: Critical — Without this fix, serverCart in zustand may never update from mutation responses, causing the same stale baseline problem this PR was designed to fix.
Minor: structuredClone on every render with pending fetchers
app/components/cart/store.ts:58 — applyOptimisticMutations uses structuredClone(baseline) on every render cycle when pending fetchers exist. Since only lines.nodes, totalQuantity, and isOptimistic are mutated, a shallow copy is sufficient and cheaper:
const nodes = [...baseline.lines.nodes] as OptimisticLineNode[];
const cart = {
...baseline,
lines: { ...baseline.lines, nodes },
totalQuantity: baseline.totalQuantity,
isOptimistic: false,
} as CartWithOptimistic & { ... };Severity: Low — negligible for typical cart sizes, but easy win.
Minor: CART_MUTATION_FRAGMENT string replacement fragility
app/graphql/fragments.ts:360-363 — String.replace() without /g only replaces the first occurrence. Currently there's only one $numCartLines, but if anyone adds another reference to the query fragment, the second occurrence silently survives as an unbound variable. Consider adding a comment explaining the intentional hardcoding.
Severity: Low — works today, maintenance risk.
Note: Suspense fallback removal changes cart badge UX
CartDrawer previously used <Suspense>/<Await> with a fallback. Now useCart() returns null until CartStoreSync's effect resolves the deferred promise, causing a brief flash of no badge → badge with count on initial page load. Not a bug, but a behavior change worth noting.
Summary
The core architecture (zustand store, custom optimistic mutations, timestamp-based staleness detection) is solid. The critical issue is the useFetchers() reliability problem — the fix for this is already implemented locally and should be committed before merging.
…fragment, remove debug logs - Replace findFreshestFetcherCart (scan all fetchers) with useCartFetcherSync hook (per-fetcher instance sync) to reliably capture post-mutation cart data before React Router cleans idle fetchers from state.fetchers map. - Add useCartFetcherSync calls to all CartForm consumers: add-to-cart-button, cart-line-item, cart-line-qty-adjust, cart-summary-actions, cart-summary. - Use replaceAll for CART_MUTATION_FRAGMENT $numCartLines replacement to prevent silent breakage if the variable appears more than once. - Remove all debug console.log statements from store.ts.
* origin/main: (61 commits) Lint fix Update version in package-lock.json to 2026.4.15 Bump version to 2026.4.15 Refactor CollectionHeader component layout and improve banner handling Update dev script port number in package.json Update dependencies in package.json Replace custom DOM events with Zustand store for product grid count Add HMR configuration option via environment variable Add dynamic product count with loaded/total format support Add content position prop to CollectionCard and CollectionsItems components Add showProductCount prop to CollectionCard and CollectionsItems components Add product count display to CollectionCard component Add collection product count route and loader for API integration Update Section width to stretch for better layout on CartRoute Fix cart page sticky positioning and overflow issues Refactor CartSummary component for improved layout and accessibility Increase transition duration for Sticky ATC Bar for smoother animations Implement footer visibility detection in Sticky ATC Bar to hide when footer is in view Refactor CartLineItem component layout for improved readability and structure Use hasOnlyDefaultVariant util in sticky ATC bar, broaden its param type ... # Conflicts: # app/components/cart/cart-drawer.tsx # app/components/cart/cart-line-item.tsx # app/routes/cart/cart-page.tsx
paul-phan
left a comment
There was a problem hiding this comment.
Code Review — PR #375
Summary
Solid fix for a real Hydrogen framework bug (useOptimisticCart double-counting + stale root loader overwrite). The custom zustand-based cart sync is well-architected and the updatedAt timestamp guard is the right approach. The fix commit (273fdaa3) addressed the initial review round well.
5 issues found (1 bug, 2 code quality, 2 maintenance).
🔴 Bug: CartStoreSync clears fresher fetcher data when root loader resolves null
File: app/components/cart/store.ts — CartStoreSync effect
When the root loader's deferred cart promise resolves to null (fresh session, no cart), it unconditionally sets serverCart: null — bypassing the updatedAt timestamp guard that protects the non-null path.
Race condition scenario:
- User lands on site (fresh session). Root loader defers
cart.get()→ will resolvenull - User clicks "Add to Cart" before promise resolves. Fetcher completes →
useCartFetcherSyncsetsserverCartto the newly-created cart - Root loader's deferred promise resolves
null(initiated pre-mutation) CartStoreSyncsees!resolved→ setsserverCart: null→ cart disappears
Fix: Guard the null path the same way:
if (!resolved) {
// Don't overwrite a fresher fetcher-synced cart with null
const current = useCartStore.getState().serverCart;
if (!current) {
useCartStore.setState({ serverCart: null });
}
return;
}Confidence: 95% — The updatedAt guard exists precisely for this class of bug but only covers the truthy branch.
🟡 AGENTS.md: any type used in 5 places across 3 files
Rule: "Avoid any type when possible, use unknown if escape hatch needed"
| File | Location | Code |
|---|---|---|
store.ts |
useCartFetcherSync param |
Fetcher<any> |
cart-line-item.tsx |
ItemRemoveButton render prop |
FetcherWithComponents<any> |
cart-line-item.tsx |
ItemRemoveButtonInner prop |
FetcherWithComponents<any> |
cart-line-qty-adjust.tsx |
UpdateCartButton render prop |
FetcherWithComponents<any> |
cart-line-qty-adjust.tsx |
UpdateCartButtonInner prop |
FetcherWithComponents<any> |
Fix: Replace with Fetcher<unknown> / FetcherWithComponents<unknown> or ideally Fetcher<{ cart: CartApiQueryFragment; userErrors: unknown[]; errors: unknown[] }>.
Confidence: 90%
🟡 Missing optimistic state for non-line mutations
applyOptimisticMutations only handles LinesAdd, LinesRemove, LinesUpdate. Other CartForm actions (DiscountCodesUpdate, GiftCardCodesAdd/Remove, NoteUpdate, BuyerIdentityUpdate) won't set isOptimistic: true while the fetcher is pending.
This means discount code application, gift card usage, and note updates won't show loading/disabled states during mutation — users can double-submit.
Fix: Add a catch-all at the end of the action switch:
default:
cart.isOptimistic = true;
break;Confidence: 80%
🟢 Stale spec documents
The 3 files in .weaverse/specs/ describe the design exploration that led to this implementation, but 2026-04-13-optimistic-cart-split-design.md describes an abandoned <Suspense><Await> + useOptimisticCart approach that doesn't match the final implementation. Future maintainers may be confused.
Fix: Add a note at the top of the split-design spec: > **Note:** This design was superseded by the zustand-based approach in store.ts.
Confidence: 85%
🟢 No automated tests for regression scenarios
The complex race conditions that motivated this PR (rapid consecutive ATC, stale root loader overwrite, double-counting) remain untested. Given this is now custom cart logic diverging from Hydrogen's built-in approach, regression coverage would be valuable.
Confidence: 85%
Review generated by code review agents. 5 parallel agents examined: AGENTS.md compliance, bug scan, git history, prior PR comments, and code comment compliance.
- Change Fetcher<any> to Fetcher<unknown> with proper type narrowing - Restore workaround comment in cart-line-item.tsx (isOptimistic fallback) - Restore backward compat comment in cart-page.tsx (GiftCardCodesUpdate)
Add removedLineIds Set to zustand store with 5-second TTL auto-expiry. When a line is optimistically removed, mark it as tombstoned. Lines in the tombstone set are filtered from the baseline cart in useCart(), preventing them from flashing back if: 1. The remove button component unmounts before sync completes 2. The fetcher scan misses the idle fetcher before cleanup 3. A stale serverCart returns from the root loader Also changed useCartFetcherSync to render-time sync with queueMicrotask to eliminate the 1-frame flash between fetcher idle and zustand update. Fixes PR #375 review feedback: 'sometime click trash(remove) the item disappear and appear again.'
applyOptimisticMutations was calling useCartStore.getState().markLineRemoved() directly during render, which caused 'Cannot update a component while rendering' error. Now we collect line IDs to mark during mutation application, then use useEffect in useCart() to actually mark them after render completes. Also added ApplyMutationsResult type for clearer return type.
The early return before useEffect caused a React Rules of Hooks violation. When baseline was null, we'd return before calling useEffect. Then when baseline appeared on the next render, useEffect would be called, changing the hook order and causing 'Cannot read properties of undefined' errors. Now we compute values without early return, call useEffect consistently, and only return null at the end. The dependency array uses fetchers.length to re-run when fetchers change.
Root cause: When remove button component unmounts before fetcher completes, useCartFetcherSync never fires to update serverCart. Item flashes back when tombstone expires after 5 seconds. Solution: Replace tombstone registry with module-level freshestFetcherCartRef that persists across component unmounts. useCart() now checks: 1. Completed fetchers from current render 2. Module-level ref (catches carts from unmounted components) 3. Zustand serverCart (fallback) This guarantees removed items stay removed even if: - Remove button unmounts before sync - Fetcher scan misses idle fetcher - Stale serverCart returns from root loader Changes: - Removed removedLineIds Set and tombstone logic from zustand store - Added module-level freshestFetcherCartRef object - Updated useCartFetcherSync to update both zustand and ref - Added findFreshestCart() helper with 3-tier priority system - useCart() now uses findFreshestCart for baseline instead of just serverCart
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
|
@hta218 please review again |
|
@hta218 friendly nudge — could you take another pass when you have a moment? Since your Apr 15 review, Paul shipped several fixes addressing the trash→reappear flash from your Loom:
PR is MERGEABLE, CI green, but blocked on your A few minor non-blocking nits I noticed if you want to address in a follow-up:
— posted on behalf of @paul-phan via Samantha |
Documents what actually shipped: the custom useCart() zustand sync that replaced Hydrogen's useOptimisticCart. Includes README, plan.md with the store.ts architecture, and a work-logs.md timeline from the two abandoned attempts to the merged solution.
Summary
Fixes cart data inconsistency issues: stale quantities, visual "drop-back" flashes, phantom qty:0, and
$numCartLinesvariable errors during cart mutations.Problems
Hydrogen's built-in
useOptimisticCarthas three design flaws that cause cart UI inconsistency:Double-counting: Processes ALL fetchers with
formDataincluding completed (idle) ones. When the mutation result is already reflected in the baseline cart, the hook re-applies the mutation, showing qty N+2 instead of N+1.Stale root loader overwrite: The deferred
cart.get()promise in the root loader is captured before mutations. When it resolves, it overwrites the fresh post-mutation cart data with stale pre-mutation state.$numCartLinesundeclared:CART_MUTATION_FRAGMENTcopiedlines(first: $numCartLines)from the query fragment, but mutation operations don't declare this variable — causing GraphQL errors on add-to-cart.Solution
Custom cart sync architecture (
app/components/cart/store.ts)findFreshestFetcherCart()— Scans completed (idle) fetchers for the freshest cart data to use as baseline, ensuringuseOptimisticCart-style optimistic mutations start from the correct state.applyOptimisticMutations()— Custom optimistic cart that ONLY processes pending (non-idle) fetchers. This prevents double-counting that Hydrogen'suseOptimisticCartcauses.useCart()— Single source of truth hook. Picks the freshest baseline (fetcher data > zustand serverCart), applies pending optimistic mutations, and syncs fetcher data to zustand viauseEffectfor persistence after fetcher cleanup.CartStoreSync()— Syncs deferred root loader cart to zustand usingupdatedAttimestamp comparison. Skips stale root loader data when a fetcher has already synced fresher post-mutation state.GraphQL fragment fix (
app/graphql/fragments.ts)CART_MUTATION_FRAGMENTnow replaces$numCartLineswith hardcoded250, preventing the undeclared variable error.Consumer simplification
CartDrawer— RemovedSuspense/Awaitwrapper and nestedCartDrawerContentcomponent. Now directly usesuseCart()hook.CartRoute— UsesuseCart()instead ofuseOptimisticCartwith loader data.AddToCartButton/CartLineItem— Updated import fromuseCartDrawerStore→useCartStore.Files Changed
app/components/cart/store.tsapp/graphql/fragments.ts$numCartLines→250in mutation fragmentapp/components/cart/cart-drawer.tsxuseCart()replacesSuspense/Await/useOptimisticCartapp/routes/cart/cart-page.tsxuseCart()replacesuseOptimisticCartapp/components/cart/cart-main.tsxapp/components/cart/cart-line-item.tsxapp/components/product/add-to-cart-button.tsxapp/.server/context.tsCART_MUTATION_FRAGMENTfor cart configapp/root.tsx<CartStoreSync />to root layoutTesting
Manually tested with rapid consecutive add-to-cart clicks. Cart quantity updates correctly without drop-back flashes or phantom zeroing.