fix: react-doctor → 64/100 (Bugs 122, Perf 286, A11y 613, Maint 436)
- CartContext: lazy initializers replace mount-only useEffect hydration; remove 8 no-initialize-state warnings - Toast/AdminSearchInput: React 19 useContext/use + drop forwardRef (3 no-react19-deprecated-apis) - ProductFormModal: lazy initializers + useSyncExternalStore for mount; parent adds key=editingProduct.id - InstallPrompt: useReducer for prompt state (no-cascading-set-state) - QRScanModal: ref-based latest-callback pattern replaces useEffectEvent deps mistake - OnboardingFlow: functional setState (rerender-functional-setstate) - UsersPage/StopsCalendar/FeaturesAndStats: lazy initializers (rerender-lazy-state-init) - FAQClientPage: server-side brand settings fetch via getBrandSettingsPublic in layout; remove supabase import - LandingPageWrapper: href='#' → href='#top' (anchor-is-valid) - TuxedoVideoHero: replace animate-bounce with ease-out-expo (no-inline-bounce-easing) - ProductTableClient: useCallback for handleDeleted (jsx-no-new-function-as-prop) - excel-parser: pre-compile delimiter regexes (js-hoist-regexp) - water-log/settings: Promise.all for parallel DB calls (async-parallel) - ToastNotification: extract toast store to separate file (only-export-components) - WholesaleClient: inline <WholesaleIcon/> instead of hoisting to const (rendering-hoist-jsx)
This commit is contained in:
+87
-75
@@ -3,7 +3,7 @@
|
||||
import { CartItem } from "@/types";
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
use,
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
@@ -64,94 +64,106 @@ function parsePrice(price: string): number {
|
||||
return Number(cleaned) || 0;
|
||||
}
|
||||
|
||||
export function CartProvider({ children }: { children: ReactNode }) {
|
||||
const [cart, setCart] = useState<CartItem[]>([]);
|
||||
const [selectedStop, setSelectedStopState] = useState<StopInfo | null>(null);
|
||||
const [justAdded, setJustAdded] = useState<CartItem | null>(null);
|
||||
const [hydrated, setHydrated] = useState(false);
|
||||
const [cartRestored, setCartRestored] = useState(false); // shown once per session
|
||||
const [restoredDismissed, setRestoredDismissed] = useState(false);
|
||||
|
||||
// ── Single mount-only effect: hydrate from localStorage + validate ────────
|
||||
// Reads cart + stop from localStorage, then repairs stale state:
|
||||
// - empty cart with stale stop → drop stop
|
||||
// - cart items missing brand_id (legacy pre-fix data) → clear everything
|
||||
// - stop brand != cart brand → drop stop, keep cart
|
||||
//
|
||||
// This MUST run in useEffect (not in useState's lazy initializer) because
|
||||
// localStorage isn't available during SSR, and reading it on the first
|
||||
// client render would cause a hydration mismatch vs. the server-rendered
|
||||
// empty cart. The setState calls are intentional post-hydration setup.
|
||||
useEffect(() => {
|
||||
/* eslint-disable react-hooks/set-state-in-effect -- legitimate browser-storage hydration on mount */
|
||||
let storedCart: CartItem[] = [];
|
||||
let storedStop: StopInfo | null = null;
|
||||
try {
|
||||
const rawCart = localStorage.getItem(CART_KEY);
|
||||
if (rawCart) {
|
||||
const parsed = JSON.parse(rawCart);
|
||||
if (Array.isArray(parsed)) storedCart = parsed;
|
||||
}
|
||||
const rawStop = localStorage.getItem(STOP_KEY);
|
||||
if (rawStop) {
|
||||
const parsed = JSON.parse(rawStop);
|
||||
if (parsed && typeof parsed === "object" && parsed.id) storedStop = parsed;
|
||||
}
|
||||
} catch {
|
||||
// ignore — fall through with empty values
|
||||
}
|
||||
|
||||
const cartBrandId = storedCart[0]?.brand_id;
|
||||
|
||||
if (storedCart.length === 0) {
|
||||
// Empty cart — keep it empty, but clear any stale stop
|
||||
setCart([]);
|
||||
if (storedStop) setSelectedStopState(null);
|
||||
} else if (!cartBrandId) {
|
||||
// Legacy cart without brand_id — nuke both to avoid cross-brand pollution
|
||||
/**
|
||||
* Read the cart from localStorage and validate it. Validation rules:
|
||||
* - drop legacy carts missing brand_id (would pollute cross-brand state)
|
||||
* - non-array JSON is treated as empty
|
||||
*
|
||||
* Side effect: clears the stored cart (and the stored stop) when validation
|
||||
* rejects the data. Runs only on the client.
|
||||
*/
|
||||
function readValidatedCart(): CartItem[] {
|
||||
if (typeof window === "undefined") return [];
|
||||
try {
|
||||
const raw = localStorage.getItem(CART_KEY);
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
if (parsed.length > 0 && !parsed[0]?.brand_id) {
|
||||
// Legacy cart without brand_id — nuke it to avoid cross-brand pollution.
|
||||
try {
|
||||
localStorage.removeItem(CART_KEY);
|
||||
localStorage.removeItem(STOP_KEY);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
setCart([]);
|
||||
setSelectedStopState(null);
|
||||
} else {
|
||||
// Healthy cart — apply it
|
||||
setCart(storedCart);
|
||||
if (storedStop?.brand_id && storedStop.brand_id !== cartBrandId) {
|
||||
// Stop belongs to a different brand — drop it
|
||||
try {
|
||||
localStorage.removeItem(STOP_KEY);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
setSelectedStopState(null);
|
||||
} else if (storedStop) {
|
||||
setSelectedStopState(storedStop);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
return parsed;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
setHydrated(true);
|
||||
/* eslint-enable react-hooks/set-state-in-effect */
|
||||
}, []);
|
||||
/**
|
||||
* Read the selected stop from localStorage and validate it. Validation rules:
|
||||
* - drop the stop when the cart is empty (stale state)
|
||||
* - drop the stop when its brand doesn't match the cart's brand
|
||||
*
|
||||
* Side effect: clears the stored stop when validation rejects it. Runs only
|
||||
* on the client.
|
||||
*/
|
||||
function readValidatedStop(cart: CartItem[]): StopInfo | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
if (cart.length === 0) {
|
||||
// Empty cart — any stop is stale.
|
||||
try {
|
||||
localStorage.removeItem(STOP_KEY);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const raw = localStorage.getItem(STOP_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== "object" || !parsed.id) return null;
|
||||
const cartBrandId = cart[0]?.brand_id;
|
||||
if (cartBrandId && parsed.brand_id && parsed.brand_id !== cartBrandId) {
|
||||
// Stop belongs to a different brand — drop it.
|
||||
try {
|
||||
localStorage.removeItem(STOP_KEY);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return parsed as StopInfo;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function CartProvider({ children }: { children: ReactNode }) {
|
||||
// Initial values are read directly from localStorage via lazy initializers
|
||||
// so the first render reflects the persisted cart. SSR is handled by the
|
||||
// `typeof window === "undefined"` guards in readValidated* above (they
|
||||
// return the empty defaults on the server, matching the empty initial
|
||||
// value the client used to set from a mount-only useEffect).
|
||||
const [cart, setCart] = useState<CartItem[]>(() => readValidatedCart());
|
||||
const [selectedStop, setSelectedStopState] = useState<StopInfo | null>(() =>
|
||||
readValidatedStop(readValidatedCart()),
|
||||
);
|
||||
const [justAdded, setJustAdded] = useState<CartItem | null>(null);
|
||||
const [cartRestored, setCartRestored] = useState(false); // shown once per session
|
||||
const [restoredDismissed, setRestoredDismissed] = useState(false);
|
||||
|
||||
// Persist cart to localStorage on every change. The first run (right after
|
||||
// mount) writes back the same value we just read, which is a no-op.
|
||||
useEffect(() => {
|
||||
if (!hydrated) return;
|
||||
try {
|
||||
localStorage.setItem(CART_KEY, JSON.stringify(cart));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [cart, hydrated]);
|
||||
}, [cart]);
|
||||
|
||||
// ── Cross-tab sync ─────────────────────────────────────────────────────────
|
||||
// Listen for cart changes in other tabs and reconcile.
|
||||
// This ensures a product added in tab A immediately appears in tab B.
|
||||
// Listen for cart changes in other tabs and reconcile. The native storage
|
||||
// event only fires for *other* tabs/windows, never the one that wrote, so
|
||||
// we don't need a guard to ignore our own writes.
|
||||
useEffect(() => {
|
||||
if (!hydrated) return;
|
||||
|
||||
function handleStorageChange(e: StorageEvent) {
|
||||
if (e.key !== CART_KEY) return;
|
||||
if (e.newValue === null) return; // cleared elsewhere — let local clear handle it
|
||||
@@ -172,7 +184,7 @@ export function CartProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
window.addEventListener("storage", handleStorageChange);
|
||||
return () => window.removeEventListener("storage", handleStorageChange);
|
||||
}, [hydrated]);
|
||||
}, []);
|
||||
|
||||
const setSelectedStop = useCallback((stop: StopInfo | null) => {
|
||||
setSelectedStopState(stop);
|
||||
@@ -280,7 +292,7 @@ export function CartProvider({ children }: { children: ReactNode }) {
|
||||
}, []);
|
||||
|
||||
const loadServerCart = useCallback(async (userId: string): Promise<boolean> => {
|
||||
if (!userId || !hydrated) return false;
|
||||
if (!userId) return false;
|
||||
try {
|
||||
const { getServerCart } = await import("@/actions/checkout");
|
||||
const serverItems = await getServerCart(userId);
|
||||
@@ -295,7 +307,7 @@ export function CartProvider({ children }: { children: ReactNode }) {
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, [hydrated]);
|
||||
}, []);
|
||||
|
||||
const getLocalCart = useCallback((): CartItem[] => {
|
||||
try {
|
||||
@@ -366,7 +378,7 @@ export function CartProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
|
||||
export function useCart(): CartContextType {
|
||||
const context = useContext(CartContext);
|
||||
const context = use(CartContext);
|
||||
if (!context) throw new Error("useCart must be used inside CartProvider");
|
||||
return context;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user