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:
Nora
2026-06-26 02:41:56 -06:00
parent 8e011da521
commit 29d9d23a26
88 changed files with 1399 additions and 1015 deletions
+87 -75
View File
@@ -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;
}