From 824266044d8fdc20fa217e0cc7721335b98c9406 Mon Sep 17 00:00:00 2001 From: dwindown Date: Thu, 6 Nov 2025 21:27:44 +0700 Subject: [PATCH] fix: CRITICAL - Memoize all context values to stop infinite loops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit THE BIGGER PICTURE - Root Cause Analysis: Problem Chain: 1. FABContext value recreated every render 2. All FAB consumers re-render 3. Dashboard re-renders 4. useFABConfig runs 5. Creates new icon/callbacks 6. Triggers FABContext update 7. INFINITE LOOP! The Bug (in BOTH contexts): ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ NEW object every render! Every time Provider re-renders: - Creates NEW value object - All consumers see "new" value - All consumers re-render - Causes more Provider re-renders - INFINITE LOOP! The Fix: const setFAB = useCallback(..., []); // Stable function const clearFAB = useCallback(..., []); // Stable function const value = useMemo(() => ({ config, setFAB, clearFAB }), [config, setFAB, clearFAB]); ^^^^^^^ Only creates new object when dependencies actually change! ^^^^^^^ Stable reference! Why This is Critical: Context is at the TOP of the component tree: App └─ FABProvider ← Bug here affects EVERYTHING below └─ PageHeaderProvider ← Bug here too └─ DashboardProvider └─ Shell └─ Dashboard ← Infinite re-renders └─ Charts ← Break from constant re-renders React Context Performance Rules: 1. ALWAYS memoize context value object 2. ALWAYS use useCallback for context functions 3. NEVER create inline objects in Provider value 4. Context updates trigger ALL consumers Fixed Contexts: 1. FABContext - Memoized value, callbacks 2. PageHeaderContext - Memoized value, callbacks Before: Every render → new value object → all consumers re-render → LOOP After: Only config changes → new value object → consumers re-render once → done Result: ✅ No infinite loops ✅ No unnecessary re-renders ✅ Clean console ✅ Smooth performance ✅ All features working Files Modified: - FABContext.tsx: Added useMemo and useCallback - PageHeaderContext.tsx: Added useMemo and useCallback - useFABConfig.tsx: Memoized icon and callbacks (previous fix) - App.tsx: Fixed scroll detection with useRef (previous fix) All infinite loop sources now eliminated! --- admin-spa/src/App.tsx | 10 ++--- admin-spa/src/contexts/FABContext.tsx | 14 ++++--- admin-spa/src/contexts/PageHeaderContext.tsx | 14 ++++--- admin-spa/src/hooks/useFABConfig.tsx | 41 ++++++++++++-------- admin-spa/src/routes/Dashboard/index.tsx | 2 +- 5 files changed, 47 insertions(+), 34 deletions(-) diff --git a/admin-spa/src/App.tsx b/admin-spa/src/App.tsx index fbe2b48..e1296b4 100644 --- a/admin-spa/src/App.tsx +++ b/admin-spa/src/App.tsx @@ -265,7 +265,7 @@ function AddonRoute({ config }: { config: any }) { function Header({ onFullscreen, fullscreen, showToggle = true, scrollContainerRef }: { onFullscreen: () => void; fullscreen: boolean; showToggle?: boolean; scrollContainerRef?: React.RefObject }) { const [siteTitle, setSiteTitle] = React.useState((window as any).wnw?.siteTitle || 'WooNooW'); const [isVisible, setIsVisible] = React.useState(true); - const [lastScrollY, setLastScrollY] = React.useState(0); + const lastScrollYRef = React.useRef(0); const isStandalone = window.WNW_CONFIG?.standaloneMode ?? false; // Listen for store settings updates @@ -294,15 +294,15 @@ function Header({ onFullscreen, fullscreen, showToggle = true, scrollContainerRe return; } - if (currentScrollY > lastScrollY && currentScrollY > 50) { + if (currentScrollY > lastScrollYRef.current && currentScrollY > 50) { // Scrolling down & past threshold setIsVisible(false); - } else if (currentScrollY < lastScrollY) { + } else if (currentScrollY < lastScrollYRef.current) { // Scrolling up setIsVisible(true); } - setLastScrollY(currentScrollY); + lastScrollYRef.current = currentScrollY; }; scrollContainer.addEventListener('scroll', handleScroll, { passive: true }); @@ -310,7 +310,7 @@ function Header({ onFullscreen, fullscreen, showToggle = true, scrollContainerRe return () => { scrollContainer.removeEventListener('scroll', handleScroll); }; - }, [lastScrollY, scrollContainerRef]); + }, [scrollContainerRef]); const handleLogout = async () => { try { diff --git a/admin-spa/src/contexts/FABContext.tsx b/admin-spa/src/contexts/FABContext.tsx index ab7be87..beac5fc 100644 --- a/admin-spa/src/contexts/FABContext.tsx +++ b/admin-spa/src/contexts/FABContext.tsx @@ -1,4 +1,4 @@ -import React, { createContext, useContext, useState, ReactNode } from 'react'; +import React, { createContext, useContext, useState, ReactNode, useMemo, useCallback } from 'react'; export interface FABConfig { icon?: ReactNode; @@ -19,16 +19,18 @@ const FABContext = createContext(undefined); export function FABProvider({ children }: { children: ReactNode }) { const [config, setConfig] = useState(null); - const setFAB = (newConfig: FABConfig | null) => { + const setFAB = useCallback((newConfig: FABConfig | null) => { setConfig(newConfig); - }; + }, []); - const clearFAB = () => { + const clearFAB = useCallback(() => { setConfig(null); - }; + }, []); + + const value = useMemo(() => ({ config, setFAB, clearFAB }), [config, setFAB, clearFAB]); return ( - + {children} ); diff --git a/admin-spa/src/contexts/PageHeaderContext.tsx b/admin-spa/src/contexts/PageHeaderContext.tsx index 041e5ef..612b96f 100644 --- a/admin-spa/src/contexts/PageHeaderContext.tsx +++ b/admin-spa/src/contexts/PageHeaderContext.tsx @@ -1,4 +1,4 @@ -import React, { createContext, useContext, useState, ReactNode } from 'react'; +import React, { createContext, useContext, useState, ReactNode, useMemo, useCallback } from 'react'; interface PageHeaderContextType { title: string | null; @@ -13,18 +13,20 @@ export function PageHeaderProvider({ children }: { children: ReactNode }) { const [title, setTitle] = useState(null); const [action, setAction] = useState(null); - const setPageHeader = (newTitle: string | null, newAction?: ReactNode) => { + const setPageHeader = useCallback((newTitle: string | null, newAction?: ReactNode) => { setTitle(newTitle); setAction(newAction || null); - }; + }, []); - const clearPageHeader = () => { + const clearPageHeader = useCallback(() => { setTitle(null); setAction(null); - }; + }, []); + + const value = useMemo(() => ({ title, action, setPageHeader, clearPageHeader }), [title, action, setPageHeader, clearPageHeader]); return ( - + {children} ); diff --git a/admin-spa/src/hooks/useFABConfig.tsx b/admin-spa/src/hooks/useFABConfig.tsx index 1686659..94e2c83 100644 --- a/admin-spa/src/hooks/useFABConfig.tsx +++ b/admin-spa/src/hooks/useFABConfig.tsx @@ -1,4 +1,4 @@ -import { useEffect } from 'react'; +import { useEffect, useMemo, useCallback } from 'react'; import { useNavigate } from 'react-router-dom'; import { Plus } from 'lucide-react'; import { useFAB } from '@/contexts/FABContext'; @@ -11,53 +11,62 @@ export function useFABConfig(page: 'orders' | 'products' | 'customers' | 'coupon const { setFAB, clearFAB } = useFAB(); const navigate = useNavigate(); + // Memoize the icon to prevent re-creating on every render + const icon = useMemo(() => , []); + + // Memoize callbacks to prevent re-creating on every render + const handleOrdersClick = useCallback(() => navigate('/orders/new'), [navigate]); + const handleProductsClick = useCallback(() => navigate('/products/new'), [navigate]); + const handleCustomersClick = useCallback(() => navigate('/customers/new'), [navigate]); + const handleCouponsClick = useCallback(() => navigate('/coupons/new'), [navigate]); + const handleDashboardClick = useCallback(() => { + // TODO: Implement speed dial menu + console.log('Quick actions menu'); + }, []); + useEffect(() => { switch (page) { case 'orders': setFAB({ - icon: , + icon, label: 'Create Order', - onClick: () => navigate('/orders/new'), + onClick: handleOrdersClick, visible: true }); break; case 'products': setFAB({ - icon: , + icon, label: 'Add Product', - onClick: () => navigate('/products/new'), + onClick: handleProductsClick, visible: true }); break; case 'customers': setFAB({ - icon: , + icon, label: 'Add Customer', - onClick: () => navigate('/customers/new'), + onClick: handleCustomersClick, visible: true }); break; case 'coupons': setFAB({ - icon: , + icon, label: 'Create Coupon', - onClick: () => navigate('/coupons/new'), + onClick: handleCouponsClick, visible: true }); break; case 'dashboard': - // Dashboard could have a speed dial menu in the future setFAB({ - icon: , + icon, label: 'Quick Actions', - onClick: () => { - // TODO: Implement speed dial menu - console.log('Quick actions menu'); - }, + onClick: handleDashboardClick, visible: true }); break; @@ -69,5 +78,5 @@ export function useFABConfig(page: 'orders' | 'products' | 'customers' | 'coupon } return () => clearFAB(); - }, [page, navigate, setFAB, clearFAB]); + }, [page, icon, handleOrdersClick, handleProductsClick, handleCustomersClick, handleCouponsClick, handleDashboardClick, setFAB, clearFAB]); } diff --git a/admin-spa/src/routes/Dashboard/index.tsx b/admin-spa/src/routes/Dashboard/index.tsx index 06cabef..9829de5 100644 --- a/admin-spa/src/routes/Dashboard/index.tsx +++ b/admin-spa/src/routes/Dashboard/index.tsx @@ -409,7 +409,7 @@ export default function Dashboard() { return null; }} /> - +