fix: CRITICAL - Memoize all context values to stop infinite loops

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):
<Context.Provider value={{ config, setFAB, clearFAB }}>
                         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
                         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!

<Context.Provider value={value}>
                        ^^^^^^^
                        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!
This commit is contained in:
dwindown
2025-11-06 21:27:44 +07:00
parent bf73ee2c02
commit 824266044d
5 changed files with 47 additions and 34 deletions

View File

@@ -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<HTMLDivElement> }) { function Header({ onFullscreen, fullscreen, showToggle = true, scrollContainerRef }: { onFullscreen: () => void; fullscreen: boolean; showToggle?: boolean; scrollContainerRef?: React.RefObject<HTMLDivElement> }) {
const [siteTitle, setSiteTitle] = React.useState((window as any).wnw?.siteTitle || 'WooNooW'); const [siteTitle, setSiteTitle] = React.useState((window as any).wnw?.siteTitle || 'WooNooW');
const [isVisible, setIsVisible] = React.useState(true); const [isVisible, setIsVisible] = React.useState(true);
const [lastScrollY, setLastScrollY] = React.useState(0); const lastScrollYRef = React.useRef(0);
const isStandalone = window.WNW_CONFIG?.standaloneMode ?? false; const isStandalone = window.WNW_CONFIG?.standaloneMode ?? false;
// Listen for store settings updates // Listen for store settings updates
@@ -294,15 +294,15 @@ function Header({ onFullscreen, fullscreen, showToggle = true, scrollContainerRe
return; return;
} }
if (currentScrollY > lastScrollY && currentScrollY > 50) { if (currentScrollY > lastScrollYRef.current && currentScrollY > 50) {
// Scrolling down & past threshold // Scrolling down & past threshold
setIsVisible(false); setIsVisible(false);
} else if (currentScrollY < lastScrollY) { } else if (currentScrollY < lastScrollYRef.current) {
// Scrolling up // Scrolling up
setIsVisible(true); setIsVisible(true);
} }
setLastScrollY(currentScrollY); lastScrollYRef.current = currentScrollY;
}; };
scrollContainer.addEventListener('scroll', handleScroll, { passive: true }); scrollContainer.addEventListener('scroll', handleScroll, { passive: true });
@@ -310,7 +310,7 @@ function Header({ onFullscreen, fullscreen, showToggle = true, scrollContainerRe
return () => { return () => {
scrollContainer.removeEventListener('scroll', handleScroll); scrollContainer.removeEventListener('scroll', handleScroll);
}; };
}, [lastScrollY, scrollContainerRef]); }, [scrollContainerRef]);
const handleLogout = async () => { const handleLogout = async () => {
try { try {

View File

@@ -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 { export interface FABConfig {
icon?: ReactNode; icon?: ReactNode;
@@ -19,16 +19,18 @@ const FABContext = createContext<FABContextType | undefined>(undefined);
export function FABProvider({ children }: { children: ReactNode }) { export function FABProvider({ children }: { children: ReactNode }) {
const [config, setConfig] = useState<FABConfig | null>(null); const [config, setConfig] = useState<FABConfig | null>(null);
const setFAB = (newConfig: FABConfig | null) => { const setFAB = useCallback((newConfig: FABConfig | null) => {
setConfig(newConfig); setConfig(newConfig);
}; }, []);
const clearFAB = () => { const clearFAB = useCallback(() => {
setConfig(null); setConfig(null);
}; }, []);
const value = useMemo(() => ({ config, setFAB, clearFAB }), [config, setFAB, clearFAB]);
return ( return (
<FABContext.Provider value={{ config, setFAB, clearFAB }}> <FABContext.Provider value={value}>
{children} {children}
</FABContext.Provider> </FABContext.Provider>
); );

View File

@@ -1,4 +1,4 @@
import React, { createContext, useContext, useState, ReactNode } from 'react'; import React, { createContext, useContext, useState, ReactNode, useMemo, useCallback } from 'react';
interface PageHeaderContextType { interface PageHeaderContextType {
title: string | null; title: string | null;
@@ -13,18 +13,20 @@ export function PageHeaderProvider({ children }: { children: ReactNode }) {
const [title, setTitle] = useState<string | null>(null); const [title, setTitle] = useState<string | null>(null);
const [action, setAction] = useState<ReactNode | null>(null); const [action, setAction] = useState<ReactNode | null>(null);
const setPageHeader = (newTitle: string | null, newAction?: ReactNode) => { const setPageHeader = useCallback((newTitle: string | null, newAction?: ReactNode) => {
setTitle(newTitle); setTitle(newTitle);
setAction(newAction || null); setAction(newAction || null);
}; }, []);
const clearPageHeader = () => { const clearPageHeader = useCallback(() => {
setTitle(null); setTitle(null);
setAction(null); setAction(null);
}; }, []);
const value = useMemo(() => ({ title, action, setPageHeader, clearPageHeader }), [title, action, setPageHeader, clearPageHeader]);
return ( return (
<PageHeaderContext.Provider value={{ title, action, setPageHeader, clearPageHeader }}> <PageHeaderContext.Provider value={value}>
{children} {children}
</PageHeaderContext.Provider> </PageHeaderContext.Provider>
); );

View File

@@ -1,4 +1,4 @@
import { useEffect } from 'react'; import { useEffect, useMemo, useCallback } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Plus } from 'lucide-react'; import { Plus } from 'lucide-react';
import { useFAB } from '@/contexts/FABContext'; import { useFAB } from '@/contexts/FABContext';
@@ -11,53 +11,62 @@ export function useFABConfig(page: 'orders' | 'products' | 'customers' | 'coupon
const { setFAB, clearFAB } = useFAB(); const { setFAB, clearFAB } = useFAB();
const navigate = useNavigate(); const navigate = useNavigate();
// Memoize the icon to prevent re-creating on every render
const icon = useMemo(() => <Plus className="w-6 h-6" />, []);
// 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(() => { useEffect(() => {
switch (page) { switch (page) {
case 'orders': case 'orders':
setFAB({ setFAB({
icon: <Plus className="w-6 h-6" />, icon,
label: 'Create Order', label: 'Create Order',
onClick: () => navigate('/orders/new'), onClick: handleOrdersClick,
visible: true visible: true
}); });
break; break;
case 'products': case 'products':
setFAB({ setFAB({
icon: <Plus className="w-6 h-6" />, icon,
label: 'Add Product', label: 'Add Product',
onClick: () => navigate('/products/new'), onClick: handleProductsClick,
visible: true visible: true
}); });
break; break;
case 'customers': case 'customers':
setFAB({ setFAB({
icon: <Plus className="w-6 h-6" />, icon,
label: 'Add Customer', label: 'Add Customer',
onClick: () => navigate('/customers/new'), onClick: handleCustomersClick,
visible: true visible: true
}); });
break; break;
case 'coupons': case 'coupons':
setFAB({ setFAB({
icon: <Plus className="w-6 h-6" />, icon,
label: 'Create Coupon', label: 'Create Coupon',
onClick: () => navigate('/coupons/new'), onClick: handleCouponsClick,
visible: true visible: true
}); });
break; break;
case 'dashboard': case 'dashboard':
// Dashboard could have a speed dial menu in the future
setFAB({ setFAB({
icon: <Plus className="w-6 h-6" />, icon,
label: 'Quick Actions', label: 'Quick Actions',
onClick: () => { onClick: handleDashboardClick,
// TODO: Implement speed dial menu
console.log('Quick actions menu');
},
visible: true visible: true
}); });
break; break;
@@ -69,5 +78,5 @@ export function useFABConfig(page: 'orders' | 'products' | 'customers' | 'coupon
} }
return () => clearFAB(); return () => clearFAB();
}, [page, navigate, setFAB, clearFAB]); }, [page, icon, handleOrdersClick, handleProductsClick, handleCustomersClick, handleCouponsClick, handleDashboardClick, setFAB, clearFAB]);
} }

View File

@@ -409,7 +409,7 @@ export default function Dashboard() {
return null; return null;
}} }}
/> />
<Legend /> <Legend wrapperStyle={{ paddingTop: '20px' }} />
<Area yAxisId="left" type="monotone" dataKey="revenue" stroke="#3b82f6" strokeWidth={2.5} fillOpacity={1} fill="url(#colorRevenue)" name={__('Revenue')} /> <Area yAxisId="left" type="monotone" dataKey="revenue" stroke="#3b82f6" strokeWidth={2.5} fillOpacity={1} fill="url(#colorRevenue)" name={__('Revenue')} />
<Line yAxisId="right" type="monotone" dataKey="orders" stroke="#10b981" strokeWidth={2.5} dot={{ r: 4, fill: '#10b981' }} activeDot={{ r: 6 }} name={__('Orders')} /> <Line yAxisId="right" type="monotone" dataKey="orders" stroke="#10b981" strokeWidth={2.5} dot={{ r: 4, fill: '#10b981' }} activeDot={{ r: 6 }} name={__('Orders')} />
</ComposedChart> </ComposedChart>