fix(admin-web): 修复 React #310 — useState 必须在条件 return 前调用

根因:expandedKeys 的 useState 调用位于 "if (!isAuthenticated) return null"
之后,违反 Rules of Hooks。认证状态变化时 hooks 调用数量不一致,
React 报 #310 "Cannot update a component while rendering a different component"。

修复:将 activeKey 计算和 expandedKeys useState 全部移至条件 return 之前,
确保每次渲染 hooks 调用顺序完全一致。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-03-04 19:05:34 -08:00
parent 300d55ff14
commit 96dad278ea
1 changed files with 16 additions and 21 deletions

View File

@ -100,28 +100,14 @@ export const AdminLayout: React.FC<{ children: React.ReactNode }> = ({ children
const { isAuthenticated, isLoading, user, logout } = useAuth(); const { isAuthenticated, isLoading, user, logout } = useAuth();
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
// 未登录 → 跳转 /login
//
// ⚠️ 必须在 useEffect 中执行,不能直接在 render 函数体内调用 router.replace()。
// 原因render 期间调用 router 会触发父组件的状态更新React #310 错误):
// "Cannot update a component while rendering a different component"
//
// useEffect 在 commit 阶段DOM 更新后)执行,此时可以安全地触发导航。
// 依赖数组包含 router 是为了满足 exhaustive-deps lint 规则router 引用稳定不会重复触发。
useEffect(() => {
if (!isLoading && !isAuthenticated) {
router.replace('/login');
}
}, [isLoading, isAuthenticated, router]);
// 加载中或未登录时显示空白
if (isLoading || !isAuthenticated) return null;
// Derive activeKey from current pathname // Derive activeKey from current pathname
// ⚠️ 必须在所有 useState/useEffect 之前计算,因为 expandedKeys 的初始值依赖它
const activeKey = pathname.replace(/^\//, '') || 'dashboard'; const activeKey = pathname.replace(/^\//, '') || 'dashboard';
// Auto-expand parent nav items based on current path // ⚠️ 所有 useState / useEffect 必须在任何条件 return 之前调用Rules of Hooks
const getInitialExpanded = () => { // 如果放在 "if (!isAuthenticated) return null" 之后,每次认证状态变化时
// hooks 调用数量不一致React 会抛出 #310 错误。
const [expandedKeys, setExpandedKeys] = useState<string[]>(() => {
const expanded: string[] = []; const expanded: string[] = [];
navItems.forEach(item => { navItems.forEach(item => {
if (item.children && item.children.some(c => activeKey.startsWith(c.key) || activeKey === item.key)) { if (item.children && item.children.some(c => activeKey.startsWith(c.key) || activeKey === item.key)) {
@ -129,8 +115,17 @@ export const AdminLayout: React.FC<{ children: React.ReactNode }> = ({ children
} }
}); });
return expanded; return expanded;
}; });
const [expandedKeys, setExpandedKeys] = useState<string[]>(getInitialExpanded);
// 未登录 → 跳转 /loginuseEffect 在 commit 阶段执行,避免 render 期间触发状态更新)
useEffect(() => {
if (!isLoading && !isAuthenticated) {
router.replace('/login');
}
}, [isLoading, isAuthenticated, router]);
// 加载中或未登录时显示空白(条件 return 必须在所有 hooks 之后)
if (isLoading || !isAuthenticated) return null;
const toggleExpand = (key: string) => { const toggleExpand = (key: string) => {
setExpandedKeys(prev => setExpandedKeys(prev =>