Skip to content

Commit cbcd093

Browse files
authored
Merge pull request #389 from Weaverse/dev
v2026.5.18 - i18n country selector, media polish, Vite 8 + dep upgrades
2 parents af890cf + c9cbd1b commit cbcd093

41 files changed

Lines changed: 2761 additions & 1477 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.weaverse/specs/2026-04-10-optimistic-cart-fix-design.md renamed to .weaverse/specs/2026-04-10--optimistic-cart-fix-attempts/2026-04-10-optimistic-cart-fix-design.md

File renamed without changes.

.weaverse/specs/2026-04-10-optimistic-cart-fix.md renamed to .weaverse/specs/2026-04-10--optimistic-cart-fix-attempts/2026-04-10-optimistic-cart-fix.md

File renamed without changes.

.weaverse/specs/2026-04-13-optimistic-cart-split-design.md renamed to .weaverse/specs/2026-04-10--optimistic-cart-fix-attempts/2026-04-13-optimistic-cart-split-design.md

File renamed without changes.
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# Feature: Optimistic Cart Fix — Abandoned Design Attempts
2+
3+
| Field | Value |
4+
| ---------------- | -------------------------------------------------------------- |
5+
| **Status** | deprecated |
6+
| **Owner** | @paul-phan |
7+
| **Branch** | `fix/optimistic-cart` |
8+
| **Created** | 2026-04-10 |
9+
| **Last Updated** | 2026-05-18 |
10+
11+
## Original Prompt
12+
13+
> Fix the optimistic cart issues in the Pilot template: cart badge not updating instantly when adding items, remove button flickering, and cart total being slow to update with skeleton state.
14+
15+
## Summary
16+
17+
Archive of two abandoned design attempts at fixing the optimistic cart. Neither
18+
shipped. Both were superseded by the custom zustand-based cart sync delivered in
19+
PR #375 — see [`../2026-05-14--optimistic-cart-zustand-sync/`](../2026-05-14--optimistic-cart-zustand-sync/).
20+
21+
## Why these are deprecated
22+
23+
Both approaches kept Hydrogen's `useOptimisticCart` and tried to fix the UI
24+
symptoms around it (where the hook was called, how the badge/drawer subscribed
25+
to the deferred cart promise). Investigation during implementation found
26+
`useOptimisticCart` itself has design flaws — double-counting idle fetchers, the
27+
stale deferred root-loader overwrite, and the undeclared `$numCartLines` mutation
28+
variable — that none of these approaches addressed. The feature was ultimately
29+
solved by replacing `useOptimisticCart` entirely with a custom zustand sync.
30+
31+
## Contents
32+
33+
| File | Attempt | Outcome |
34+
|------|---------|---------|
35+
| `2026-04-10-optimistic-cart-fix-design.md` | Lift `useOptimisticCart` from `CartMain` into `CartDrawer` / `CartRoute` | Abandoned (self-marked `superseded`) |
36+
| `2026-04-10-optimistic-cart-fix.md` | 592-line implementation plan for the lift approach | Abandoned — never executed |
37+
| `2026-04-13-optimistic-cart-split-design.md` | Split trigger/drawer into two independent `<Await>` + `useOptimisticCart` blocks | Abandoned |
38+
39+
> Note: original filenames are preserved intentionally. This is a tombstone
40+
> archive, so the standard SDD `plan.md` / `README.md` file layout is not
41+
> enforced for the historical documents themselves.
42+
43+
## Superseded by
44+
45+
- Shipped spec: [`../2026-05-14--optimistic-cart-zustand-sync/`](../2026-05-14--optimistic-cart-zustand-sync/)
46+
- Related stale spec (lift approach, abandoned): [`../2026-04-10--optimistic-cart-fix/`](../2026-04-10--optimistic-cart-fix/)
47+
- PR: <https://github.com/Weaverse/pilot/pull/375>

.weaverse/specs/2026-04-10--optimistic-cart-fix/README.md

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,12 @@
22

33
| Field | Value |
44
| ---------------- | -------------------------------------------------------- |
5-
| **Status** | approved |
5+
| **Status** | deprecated |
66
| **Owner** | Paul Phan |
77
| **Issue** | N/A |
88
| **Branch** | `fix/optimistic-cart` |
99
| **Created** | 2026-04-10 |
10-
| **Last Updated** | 2026-04-10 |
10+
| **Last Updated** | 2026-05-18 |
1111

1212
## Original Prompt
1313

@@ -17,6 +17,21 @@
1717

1818
Refactors the optimistic cart implementation by lifting `useOptimisticCart` from `CartMain` into parent components (`CartDrawer` and `CartRoute`), ensuring badge count, drawer title, and line items all share the same optimistic state. Removes the redundant dual-mechanism removal (CSS + hook) to eliminate flicker when removing items.
1919

20+
## Resolution — Abandoned, Not Implemented
21+
22+
> **This lift-`useOptimisticCart` approach was never implemented.** It was
23+
> abandoned because `useOptimisticCart` itself proved flawed (double-counting
24+
> idle fetchers, stale deferred root-loader overwrite, undeclared
25+
> `$numCartLines`) — problems lifting the hook could not fix.
26+
>
27+
> - **What shipped instead:** a custom zustand-based cart sync — see
28+
> [`../2026-05-14--optimistic-cart-zustand-sync/`](../2026-05-14--optimistic-cart-zustand-sync/)
29+
> (PR [#375](https://github.com/Weaverse/pilot/pull/375), merged 2026-05-14).
30+
> - **Related abandoned design attempts:**
31+
> [`../2026-04-10--optimistic-cart-fix-attempts/`](../2026-04-10--optimistic-cart-fix-attempts/).
32+
>
33+
> The original problem analysis below is retained for historical context only.
34+
2035
## Problem
2136

2237
The Shopify Hydrogen Optimistic Cart in the Pilot template has three observable issues:
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# Feature: Optimistic Cart — Custom Zustand Cart Sync
2+
3+
| Field | Value |
4+
| ---------------- | -------------------------------------------------------------- |
5+
| **Status** | completed |
6+
| **Owner** | @paul-phan |
7+
| **PR** | [#375](https://github.com/Weaverse/pilot/pull/375) — merged 2026-05-14 |
8+
| **Branch** | `fix/optimistic-cart` |
9+
| **Created** | 2026-05-14 |
10+
| **Last Updated** | 2026-05-18 |
11+
12+
## Original Prompt
13+
14+
> Fix the optimistic cart issues in the Pilot template: cart badge not updating instantly when adding items, remove button flickering, and cart total being slow to update with skeleton state.
15+
16+
## Summary
17+
18+
Replaces Hydrogen's `useOptimisticCart` with a custom zustand-based cart sync
19+
(`useCart()`) that fixes cart data inconsistency: stale quantities, visual
20+
"drop-back" flashes, phantom `qty:0`, and `$numCartLines` GraphQL errors during
21+
cart mutations. This is the approach that actually shipped, superseding the
22+
abandoned attempts archived in
23+
[`../2026-04-10--optimistic-cart-fix-attempts/`](../2026-04-10--optimistic-cart-fix-attempts/).
24+
25+
## Problem
26+
27+
Hydrogen's built-in `useOptimisticCart` has three design flaws that cause cart
28+
UI inconsistency:
29+
30+
1. **Double-counting** — it processes ALL fetchers carrying `formData`, including
31+
completed (idle) ones. When the mutation result is already reflected in the
32+
baseline cart, the hook re-applies the mutation → shows qty `N+2` instead of
33+
`N+1`.
34+
2. **Stale root-loader overwrite** — the deferred `cart.get()` promise in the
35+
root loader is captured before mutations. When it resolves it overwrites the
36+
fresh post-mutation cart with stale pre-mutation state.
37+
3. **`$numCartLines` undeclared**`CART_MUTATION_FRAGMENT` copied
38+
`lines(first: $numCartLines)` from the query fragment, but mutation operations
39+
don't declare that variable → GraphQL error on add-to-cart.
40+
41+
## Outcome
42+
43+
Shipped and merged in PR #375. Manually verified with rapid consecutive
44+
add-to-cart clicks — cart quantity updates correctly without drop-back flashes
45+
or phantom zeroing. Implementation details in [`plan.md`](./plan.md); the path
46+
from the abandoned attempts to this design is in [`work-logs.md`](./work-logs.md).
47+
48+
**Follow-up (2026-05-18):** fixed a post-merge bug where removing a line item
49+
left the checkout-button total stale (no `store.ts` change required). See
50+
[`plan.md` → Follow-up fixes](./plan.md#follow-up-fixes-post-merge) and the
51+
[`work-logs.md`](./work-logs.md) entry.
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
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.
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# Work Logs
2+
3+
## 2026-04-10 — @paul-phan
4+
- Identified three optimistic cart issues: stale badge/title on add-to-cart,
5+
remove-item flicker, slow cart total skeleton.
6+
- Designed **Attempt 1**: lift Hydrogen's `useOptimisticCart` from `CartMain`
7+
into `CartDrawer` / `CartRoute` so badge, title, line items, and summary
8+
share one optimistic state; remove the dual `OptimisticInput` + CSS-hide
9+
removal mechanism.
10+
- Wrote a 592-line implementation plan for the lift approach.
11+
- Archived: `../2026-04-10--optimistic-cart-fix-attempts/2026-04-10-optimistic-cart-fix-design.md`
12+
and `...-fix.md`.
13+
14+
## 2026-04-13 — @paul-phan
15+
- Lift approach abandoned. Designed **Attempt 2**: split the trigger badge and
16+
drawer content into two independent `<Await>` + `useOptimisticCart` blocks
17+
(matching the Hydrogen skeleton pattern); drop the `lastCartRef` hack which
18+
had caused deleted items to reappear.
19+
- Archived: `../2026-04-10--optimistic-cart-fix-attempts/2026-04-13-optimistic-cart-split-design.md`.
20+
21+
## ~2026-05-14 — @paul-phan
22+
- Both prior attempts abandoned. Root finding: `useOptimisticCart` **itself**
23+
is flawed — double-counts idle fetchers, gets overwritten by the stale
24+
deferred root-loader promise, and the mutation fragment referenced an
25+
undeclared `$numCartLines`. Lifting or splitting the hook could not fix
26+
these.
27+
- Decided to **replace `useOptimisticCart` entirely** with a custom
28+
zustand-based `useCart()` sync (see `plan.md`).
29+
- Shipped in PR #375 (`fix/optimistic-cart`), merged 2026-05-14.
30+
- Manually verified with rapid consecutive add-to-cart clicks.
31+
32+
## 2026-05-18 — @hta218
33+
- Reconciled the spec folder with reality:
34+
- Moved the 3 loose root-level markdown files into the SDD-compliant
35+
`../2026-04-10--optimistic-cart-fix-attempts/` archive (status `deprecated`).
36+
- Created this folder to document the shipped zustand approach.
37+
- Marked the stale `../2026-04-10--optimistic-cart-fix/` lift-approach folder
38+
`deprecated` (abandoned, never implemented).
39+
40+
## 2026-05-18 — @hta218 (follow-up fix)
41+
- **Bug:** removing a line item via the trash button did not update the
42+
checkout-button total (drawer and page). The total only refreshed on a
43+
later add / quantity change.
44+
- **Root cause:** the remove `CartForm` used an anonymous fetcher, and the
45+
only thing syncing its response (`ItemRemoveButtonInner`) unmounts the
46+
instant the line is optimistically spliced out — so React Router discards
47+
the fetcher and the authoritative post-remove cart (with the correct
48+
`cost`) is lost. Both fallbacks in `store.ts` (`applyOptimisticMutations`
49+
and the `removedLineIds` tombstone filter) recompute `totalQuantity` but
50+
never `cost`.
51+
- **Fix:** gave the remove `CartForm` a stable `fetcherKey="cart-line-remove"`
52+
and read it from the always-mounted `CartSummary` via
53+
`useFetcher({ key: "cart-line-remove" })` + `useCartFetcherSync(...)`,
54+
mirroring the existing discount-code / gift-card removal pattern. Also
55+
added it to `isCartUpdating` so the total shows a skeleton during the
56+
transition instead of flashing the stale value.
57+
- **No `store.ts` changes** — the tombstone / `useCart()` design held up; the
58+
gap was purely a missing keyed fetcher. Server-computed cost is now used
59+
(accurate with cart-level discounts/taxes — no client-side cost math).
60+
- Verified manually: removing a non-last item updates the total correctly in
61+
both drawer and page; rapid multi-remove converges.

0 commit comments

Comments
 (0)