Skip to content

Commit ea9b46d

Browse files
fix(pwa checkout): wait for cart hydration before empty-cart redirect (no more false /panier bounce) (#492)
* @ test(pwa checkout): pin hydration gate before empty-cart redirect Red tests for the false /panier bounce: a fresh /checkout load with a non-empty PERSISTED cart must not redirect. Cover the decideCheckoutRedirect hydration matrix (wait until hydrated; stay on non-empty; redirect on empty) and the safeReadFromStorage rehydration source (persisted non-empty cart reads back non-empty). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> @ * @ fix(pwa checkout): wait for cart hydration before empty-cart redirect The cart is localStorage-backed; CartProvider rehydrates in a mount effect that React commits AFTER the child CheckoutForm redirect effect. On a fresh /checkout load the form read 0 lines (pre-rehydration EMPTY state) and bounced a non-empty persisted cart to /panier. Expose `hydrated` from the cart context (false on server + first client render for SSR safety, flipped true once the mount effect has read localStorage even when empty). decideCheckoutRedirect now returns `wait` until hydrated; the form renders nothing while waiting — no redirect, no form flash. Post-hydration: empty -> /panier (preserved), non-empty -> render the form. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> @ --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 51e9558 commit ea9b46d

5 files changed

Lines changed: 238 additions & 17 deletions

File tree

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/**
2+
* PWA — `<CartProvider>` rehydration core, exercised through its pure helper
3+
* `safeReadFromStorage` (same node-env, no-jsdom convention as the rest of the
4+
* PWA decision suite — see `checkout-gate/*.test.ts`).
5+
*
6+
* Regression context (« /checkout bounce to /panier with items in cart »):
7+
* the cart is localStorage-backed and rehydrates in a mount effect. The
8+
* `<CheckoutForm>` empty-cart → /panier redirect must wait for that effect
9+
* (gated on the context's `hydrated` flag) so a non-empty PERSISTED cart isn't
10+
* bounced. The two pieces that close the race are unit-pinned here + in
11+
* `checkout-gate/decide-checkout-redirect.test.ts`:
12+
*
13+
* 1. the redirect decision returns `wait` until `hydrated` (decision test),
14+
* 2. a persisted non-empty cart is read back as non-empty so that, once
15+
* hydrated, the decision is `stay` not `redirect` (THIS file).
16+
*/
17+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
18+
import { safeReadFromStorage } from "./cart-context";
19+
import { EMPTY_CART, type CartState } from "@/lib/cart-store";
20+
21+
const STORAGE_KEY = "kb-cart-v1";
22+
23+
/** Minimal in-memory localStorage stub for node env (no jsdom). */
24+
function installLocalStorage(): Map<string, string> {
25+
const store = new Map<string, string>();
26+
const fake = {
27+
getItem: (k: string): string | null => store.get(k) ?? null,
28+
setItem: (k: string, v: string): void => {
29+
store.set(k, v);
30+
},
31+
removeItem: (k: string): void => {
32+
store.delete(k);
33+
},
34+
clear: (): void => {
35+
store.clear();
36+
},
37+
};
38+
// `safeReadFromStorage` guards on `typeof window === "undefined"`, so we must
39+
// expose BOTH `window` and `window.localStorage`.
40+
(globalThis as { window?: unknown }).window = { localStorage: fake };
41+
return store;
42+
}
43+
44+
function uninstallLocalStorage(): void {
45+
delete (globalThis as { window?: unknown }).window;
46+
}
47+
48+
const PERSISTED_NON_EMPTY: CartState = {
49+
lines: [
50+
{
51+
lineId: "item_1::",
52+
itemId: "item_1",
53+
name: "Smash Double",
54+
basePriceCentimes: 1290,
55+
photoUrl: null,
56+
modifiers: [],
57+
qty: 2,
58+
},
59+
],
60+
note: "Sans oignon",
61+
};
62+
63+
describe("safeReadFromStorage (cart rehydration source)", () => {
64+
let store: Map<string, string>;
65+
66+
beforeEach(() => {
67+
store = installLocalStorage();
68+
});
69+
70+
afterEach(() => {
71+
uninstallLocalStorage();
72+
});
73+
74+
it("reads a persisted NON-EMPTY cart back as non-empty (no false /panier bounce)", () => {
75+
// This is the exact data that, post-hydration, makes the checkout redirect
76+
// decision `stay` instead of bouncing to /panier. If this regressed, the
77+
// hydration gate would still hide the form forever.
78+
store.set(STORAGE_KEY, JSON.stringify(PERSISTED_NON_EMPTY));
79+
80+
const read = safeReadFromStorage();
81+
82+
expect(read.lines.length).toBe(1);
83+
expect(read.lines[0]?.qty).toBe(2);
84+
expect(read.note).toBe("Sans oignon");
85+
});
86+
87+
it("returns EMPTY_CART when nothing is persisted (genuine empty cart → /panier)", () => {
88+
expect(safeReadFromStorage()).toEqual(EMPTY_CART);
89+
});
90+
91+
it("falls back to EMPTY_CART on corrupted JSON (never throws)", () => {
92+
store.set(STORAGE_KEY, "{not-valid-json");
93+
expect(safeReadFromStorage()).toEqual(EMPTY_CART);
94+
});
95+
96+
it("falls back to EMPTY_CART on a shape-invalid payload (defensive)", () => {
97+
store.set(STORAGE_KEY, JSON.stringify({ lines: "nope", note: 42 }));
98+
expect(safeReadFromStorage()).toEqual(EMPTY_CART);
99+
});
100+
101+
it("returns EMPTY_CART when there is no window (SSR — hydrated stays false)", () => {
102+
uninstallLocalStorage();
103+
expect(safeReadFromStorage()).toEqual(EMPTY_CART);
104+
store = installLocalStorage(); // restore for afterEach symmetry
105+
});
106+
});

apps/web/src/components/cart/cart-context.tsx

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
useContext,
3232
useEffect,
3333
useReducer,
34+
useState,
3435
type Dispatch,
3536
type ReactNode,
3637
} from "react";
@@ -51,6 +52,16 @@ type CartContextValue = {
5152
state: CartState;
5253
totals: CartTotals;
5354
dispatch: Dispatch<CartAction>;
55+
/**
56+
* `false` on the server + the very first client render, flipped to `true`
57+
* once the mount effect has read `localStorage` (whether the stored cart
58+
* was empty or not — hydration means « we've read storage », NOT « the cart
59+
* is non-empty »). Consumers that gate navigation on the cart contents
60+
* (e.g. `<CheckoutForm>`'s empty-cart → /panier redirect) MUST wait for
61+
* `hydrated === true` before deciding, otherwise they race the rehydration
62+
* effect and bounce a non-empty persisted cart back to /panier.
63+
*/
64+
hydrated: boolean;
5465
/** Sugar: dispatch ADD_LINE; the most common cart action. */
5566
addLine: (
5667
item: CartItemView,
@@ -61,8 +72,15 @@ type CartContextValue = {
6172

6273
const CartCtx = createContext<CartContextValue | null>(null);
6374

64-
/** Defensive rehydrate: shape-check the JSON before trusting it. */
65-
function safeReadFromStorage(): CartState {
75+
/**
76+
* Defensive rehydrate: shape-check the JSON before trusting it.
77+
*
78+
* Exported (not just an internal helper) so the rehydration contract — « a
79+
* persisted non-empty cart is read back as non-empty » — is vitest-pinnable
80+
* in node env alongside the rest of the cart decisions. This is the source
81+
* the mount effect replays into the reducer.
82+
*/
83+
export function safeReadFromStorage(): CartState {
6684
if (typeof window === "undefined") return EMPTY_CART;
6785
try {
6886
const raw = window.localStorage.getItem(STORAGE_KEY);
@@ -95,9 +113,21 @@ export function CartProvider({
95113
// throw a warning if we initialised from a non-deterministic source).
96114
const [state, dispatch] = useReducer(cartReducer, EMPTY_CART);
97115

116+
// `false` until the mount effect has read localStorage. Stays `false` on the
117+
// server + first client render so SSR markup matches (localStorage isn't
118+
// readable server-side) — no hydration mismatch. Consumers gate navigation
119+
// on this so they never decide the empty-cart redirect against the
120+
// pre-rehydration EMPTY state (the false /panier-bounce race).
121+
const [hydrated, setHydrated] = useState(false);
122+
98123
// Rehydrate once on mount.
99124
useEffect(() => {
100125
const stored = safeReadFromStorage();
126+
// Flag hydration as DONE regardless of whether the stored cart had items —
127+
// « hydrated » means « we've read storage », not « the cart is non-empty ».
128+
// Set it even on the empty-cart early-return below so a genuinely empty
129+
// cart still unblocks the consumer's (correct) redirect to /panier.
130+
setHydrated(true);
101131
if (stored.lines.length === 0 && stored.note === "") return;
102132
// Replace state with the persisted one — we don't have a REPLACE action
103133
// in the reducer (deliberately minimal); we re-add each line in order
@@ -145,7 +175,7 @@ export function CartProvider({
145175
);
146176

147177
return (
148-
<CartCtx.Provider value={{ state, totals, dispatch, addLine }}>
178+
<CartCtx.Provider value={{ state, totals, dispatch, addLine, hydrated }}>
149179
{children}
150180
</CartCtx.Provider>
151181
);

apps/web/src/components/checkout/checkout-form.tsx

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -84,14 +84,21 @@ export function CheckoutForm({
8484
// from another tab / from an earlier modal.
8585
const customer = usePreloadedQuery(preloadedCustomer);
8686

87-
const { state: cartState, totals } = useCart();
87+
const { state: cartState, totals, hydrated } = useCart();
8888
const { verdict, mode } = useDeliveryMode();
8989

9090
// Decide redirect on every render (the cart can be emptied from another
9191
// tab in private mode → we react). `useEffect` so the navigation happens
9292
// post-commit (Next 16 will scream if router.push runs during render).
93+
//
94+
// `hydrated` is the race guard: the cart rehydrates from localStorage in
95+
// `<CartProvider>`'s mount effect, which React commits AFTER this child's
96+
// redirect effect. Without the guard a fresh /checkout load reads 0 lines
97+
// (the pre-rehydration EMPTY state) and bounces a non-empty persisted cart
98+
// to /panier. Until hydrated, the decision is `wait` → no redirect, no form.
9399
const redirect = decideCheckoutRedirect({
94100
cartLineCount: cartState.lines.length,
101+
hydrated,
95102
});
96103
useEffect(() => {
97104
if (redirect.kind === "redirect") {
@@ -135,9 +142,11 @@ export function CheckoutForm({
135142
[],
136143
);
137144

138-
if (redirect.kind === "redirect") {
139-
// Render nothing while the navigation is en route — avoids a flash of
140-
// the empty checkout form before the redirect lands.
145+
if (redirect.kind !== "stay") {
146+
// `wait` → cart hasn't hydrated from localStorage yet: render nothing
147+
// so we neither flash the form nor bounce a persisted cart.
148+
// `redirect` → empty cart confirmed post-hydration: render nothing while
149+
// the navigation to /panier is en route (no empty-form flash).
141150
return null;
142151
}
143152

apps/web/src/lib/checkout-gate/decide-checkout-redirect.test.ts

Lines changed: 56 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,23 +14,73 @@ import { describe, expect, it } from "vitest";
1414
import { decideCheckoutRedirect } from "./decide-checkout-redirect";
1515

1616
describe("decideCheckoutRedirect", () => {
17-
it("redirects to /panier when the cart has 0 lines", () => {
18-
const action = decideCheckoutRedirect({ cartLineCount: 0 });
17+
it("redirects to /panier when the cart has 0 lines (hydrated)", () => {
18+
const action = decideCheckoutRedirect({ cartLineCount: 0, hydrated: true });
1919
expect(action.kind).toBe("redirect");
2020
if (action.kind !== "redirect") throw new Error("unreachable");
2121
expect(action.path).toBe("/panier");
2222
});
2323

24-
it("stays on the page when the cart has 1+ line(s)", () => {
25-
expect(decideCheckoutRedirect({ cartLineCount: 1 }).kind).toBe("stay");
26-
expect(decideCheckoutRedirect({ cartLineCount: 5 }).kind).toBe("stay");
24+
it("stays on the page when the cart has 1+ line(s) (hydrated)", () => {
25+
expect(
26+
decideCheckoutRedirect({ cartLineCount: 1, hydrated: true }).kind,
27+
).toBe("stay");
28+
expect(
29+
decideCheckoutRedirect({ cartLineCount: 5, hydrated: true }).kind,
30+
).toBe("stay");
2731
});
2832

2933
it("treats a negative count defensively as 0 (no crash on bogus input)", () => {
3034
// Pure functions should never throw on numerical edge values — the cart
3135
// store should never produce a negative count, but if it did, behaving
3236
// like « empty cart » is the safe choice (redirect, do NOT render the
3337
// checkout form against a bogus state).
34-
expect(decideCheckoutRedirect({ cartLineCount: -1 }).kind).toBe("redirect");
38+
expect(
39+
decideCheckoutRedirect({ cartLineCount: -1, hydrated: true }).kind,
40+
).toBe("redirect");
41+
});
42+
43+
// ── Hydration-race regression (false /panier bounce) ──────────────────
44+
// The cart is localStorage-backed and rehydrates in a mount effect that
45+
// runs AFTER the child <CheckoutForm> redirect effect (React fires child
46+
// effects before parent effects). On a fresh /checkout load the form would
47+
// momentarily see 0 lines even though localStorage HAS items → it bounced
48+
// to /panier. The fix gates the decision behind `hydrated`: until the cart
49+
// has read localStorage we must neither redirect nor render the form.
50+
describe("hydration gate (no false /panier bounce)", () => {
51+
it("returns `wait` (never redirect) while the cart is NOT hydrated, even with 0 lines", () => {
52+
// 0 lines pre-hydration is the EXACT race that caused the bug: the
53+
// store starts EMPTY before localStorage is read. We must NOT redirect.
54+
const action = decideCheckoutRedirect({
55+
cartLineCount: 0,
56+
hydrated: false,
57+
});
58+
expect(action.kind).toBe("wait");
59+
});
60+
61+
it("returns `wait` while not hydrated regardless of the (stale) line count", () => {
62+
expect(
63+
decideCheckoutRedirect({ cartLineCount: 3, hydrated: false }).kind,
64+
).toBe("wait");
65+
expect(
66+
decideCheckoutRedirect({ cartLineCount: -1, hydrated: false }).kind,
67+
).toBe("wait");
68+
});
69+
70+
it("once hydrated, a non-empty cart STAYS (no bounce) — the bug is closed", () => {
71+
expect(
72+
decideCheckoutRedirect({ cartLineCount: 2, hydrated: true }).kind,
73+
).toBe("stay");
74+
});
75+
76+
it("once hydrated, a genuinely empty cart still redirects to /panier", () => {
77+
const action = decideCheckoutRedirect({
78+
cartLineCount: 0,
79+
hydrated: true,
80+
});
81+
expect(action.kind).toBe("redirect");
82+
if (action.kind !== "redirect") throw new Error("unreachable");
83+
expect(action.path).toBe("/panier");
84+
});
3585
});
3686
});

apps/web/src/lib/checkout-gate/decide-checkout-redirect.ts

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,19 +12,42 @@
1212
* function just answers « given a line count, what should happen? ».
1313
*/
1414

15-
/** The input the decision needs — just the cart line count for now. */
15+
/**
16+
* The input the decision needs : the cart line count AND whether the cart
17+
* context has finished reading `localStorage`.
18+
*
19+
* `hydrated` closes the false-/panier-bounce race (issue: « /checkout bounce
20+
* to /panier with items in cart »). The cart is localStorage-backed and the
21+
* `<CartProvider>` rehydration runs in a mount effect that fires AFTER the
22+
* child `<CheckoutForm>` redirect effect (React commits child effects before
23+
* parent effects). On a fresh `/checkout` load the form would briefly read 0
24+
* lines even though localStorage HAS items → redirect to /panier. Until the
25+
* cart has read storage we must hold the decision (`wait`), never redirect.
26+
*/
1627
export type CheckoutRedirectInput = {
1728
cartLineCount: number;
29+
/** `true` once `<CartProvider>` has read localStorage (empty or not). */
30+
hydrated: boolean;
1831
};
1932

20-
/** The action the calling component should perform. */
33+
/**
34+
* The action the calling component should perform.
35+
*
36+
* `wait` = the cart hasn't hydrated yet : render nothing, do NOT redirect and
37+
* do NOT flash the form (we don't yet know if the persisted cart has items).
38+
*/
2139
export type CheckoutRedirectAction =
40+
| { kind: "wait" }
2241
| { kind: "stay" }
2342
| { kind: "redirect"; path: "/panier" };
2443

2544
/**
26-
* Decide the redirect for `/checkout`. `cartLineCount <= 0` redirects to
27-
* /panier; any positive count stays.
45+
* Decide the redirect for `/checkout`.
46+
*
47+
* - not hydrated yet → `wait` (the localStorage cart is unknown; holding
48+
* avoids the false bounce to /panier on a fresh load with persisted items),
49+
* - hydrated + `cartLineCount <= 0` → redirect to /panier,
50+
* - hydrated + positive count → stay.
2851
*
2952
* Negative counts are treated like 0 (defensive: pure functions don't throw
3053
* on numerical edge cases, and « redirect to /panier on bogus state » is
@@ -34,6 +57,9 @@ export type CheckoutRedirectAction =
3457
export function decideCheckoutRedirect(
3558
input: CheckoutRedirectInput,
3659
): CheckoutRedirectAction {
60+
if (!input.hydrated) {
61+
return { kind: "wait" };
62+
}
3763
if (input.cartLineCount <= 0) {
3864
return { kind: "redirect", path: "/panier" };
3965
}

0 commit comments

Comments
 (0)