fix: Zustand selector anti-pattern causing infinite re-render + plugin loader path
- Fix usePluginStore(s => s.getAll*()) selectors that returned new arrays on every call, causing infinite re-render loops and permanent spinner - Affected: Sidebar, Settings, ContactDetail, ContactEditModal, PluginRouteRenderer - Use usePluginStore(s => s.manifests) + useMemo instead - Fix PluginRouteRenderer: show spinner instead of null when manifests loading - Fix PluginLoader: @/ path replacement ../ -> ../../ for correct dynamic imports
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
@@ -157,7 +157,14 @@ export function ContactDetail({ contact, loading, onEdit, onDeleted }: ContactDe
|
||||
const [personModalOpen, setPersonModalOpen] = useState(false);
|
||||
const [editingPerson, setEditingPerson] = useState<ContactPerson | null>(null);
|
||||
const [activeTab, setActiveTab] = useState('details');
|
||||
const pluginTabs = usePluginStore(s => s.getDetailTabsForEntity('contact'));
|
||||
const manifests = usePluginStore(s => s.manifests);
|
||||
const pluginTabs = useMemo(
|
||||
() => manifests
|
||||
.flatMap((m) => m.detail_tabs)
|
||||
.filter((t) => t.entity_type === 'contact')
|
||||
.sort((a, b) => a.order - b.order),
|
||||
[manifests]
|
||||
);
|
||||
const aiActiveTab = useAIUIControlStore(s => s.activeTab);
|
||||
const aiActiveModal = useAIUIControlStore(s => s.activeModal);
|
||||
|
||||
@@ -180,7 +187,12 @@ export function ContactDetail({ contact, loading, onEdit, onDeleted }: ContactDe
|
||||
const user = useAuthStore(s => s.user);
|
||||
|
||||
// Custom fields from plugin definitions
|
||||
const customFieldDefs = usePluginStore(s => s.getCustomFieldsForEntity('contact'));
|
||||
const customFieldDefs = useMemo(
|
||||
() => manifests
|
||||
.flatMap((m) => m.custom_fields || [])
|
||||
.filter((cf) => cf.entity === 'contact'),
|
||||
[manifests]
|
||||
);
|
||||
const { data: customFieldsData } = useCustomFields(contact?.id);
|
||||
|
||||
if (loading) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
@@ -109,7 +109,13 @@ export function ContactEditModal({ open, onClose, contact, onSaved }: ContactEdi
|
||||
const currentType = watch('type');
|
||||
|
||||
// Custom fields
|
||||
const customFieldDefs = usePluginStore(s => s.getCustomFieldsForEntity('contact'));
|
||||
const manifests = usePluginStore(s => s.manifests);
|
||||
const customFieldDefs = useMemo(
|
||||
() => manifests
|
||||
.flatMap((m) => m.custom_fields || [])
|
||||
.filter((cf) => cf.entity === 'contact'),
|
||||
[manifests]
|
||||
);
|
||||
const { data: customFieldsData } = useCustomFields(contact?.id);
|
||||
const updateCustomFields = useUpdateCustomFields();
|
||||
const [customValues, setCustomValues] = React.useState<Record<string, any>>({});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { NavLink, useLocation } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -38,7 +38,13 @@ export function Sidebar() {
|
||||
const { t } = useTranslation();
|
||||
const { sidebarOpen, setSidebarOpen } = useUIStore();
|
||||
const location = useLocation();
|
||||
const pluginMenuItems = usePluginStore(s => s.getAllMenuItems());
|
||||
const manifests = usePluginStore(s => s.manifests);
|
||||
const pluginMenuItems = useMemo(
|
||||
() => manifests
|
||||
.flatMap((m) => m.menu_items)
|
||||
.sort((a, b) => a.order - b.order),
|
||||
[manifests]
|
||||
);
|
||||
|
||||
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
|
||||
|
||||
|
||||
@@ -40,8 +40,9 @@ function getLazyComponent(componentPath: string): React.LazyExoticComponent<Reac
|
||||
return componentCache.get(componentPath)!;
|
||||
}
|
||||
|
||||
// Convert @/pages/Mail to ../pages/Mail for dynamic import
|
||||
const importPath = componentPath.replace(/^@\//, '../');
|
||||
// Convert @/pages/Mail to ../../pages/Mail for dynamic import
|
||||
// PluginLoader.tsx is in src/components/plugins/, so ../../ resolves to src/
|
||||
const importPath = componentPath.replace(/^@\//, '../../');
|
||||
|
||||
const LazyComp = lazy(() =>
|
||||
import(/* @vite-ignore */ importPath).then((m) => ({
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { usePluginStore } from '@/store/pluginStore';
|
||||
import { PluginPage } from './PluginLoader';
|
||||
|
||||
@@ -15,10 +17,15 @@ import { PluginPage } from './PluginLoader';
|
||||
*/
|
||||
export function PluginRouteRenderer() {
|
||||
const location = useLocation();
|
||||
const getAllPageRoutes = usePluginStore((s) => s.getAllPageRoutes);
|
||||
const manifests = usePluginStore((s) => s.manifests);
|
||||
const loaded = usePluginStore((s) => s.loaded);
|
||||
|
||||
const routes = getAllPageRoutes();
|
||||
const routes = useMemo(
|
||||
() => manifests
|
||||
.flatMap((m) => m.page_routes)
|
||||
.sort((a, b) => a.order - b.order),
|
||||
[manifests]
|
||||
);
|
||||
|
||||
// Find the first matching route (exact match or prefix match for nested routes)
|
||||
const matchedRoute = routes.find((r) => {
|
||||
@@ -31,7 +38,6 @@ export function PluginRouteRenderer() {
|
||||
|
||||
if (matchedRoute) {
|
||||
// Find the plugin name for display
|
||||
const manifests = usePluginStore.getState().manifests;
|
||||
const plugin = manifests.find((m) =>
|
||||
m.page_routes.some((pr) => pr.path === matchedRoute.path)
|
||||
);
|
||||
@@ -45,9 +51,13 @@ export function PluginRouteRenderer() {
|
||||
);
|
||||
}
|
||||
|
||||
// If manifests haven't loaded yet, show nothing (the static router handles it)
|
||||
// If manifests haven't loaded yet, show a spinner (not null/blank)
|
||||
if (!loaded) {
|
||||
return null;
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[50vh]" role="status" aria-label="Loading">
|
||||
<Loader2 className="animate-spin h-8 w-8 text-primary-500" aria-hidden="true" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// No plugin route matched — show a simple not-found
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { NavLink, Outlet } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePluginStore } from '@/store/pluginStore';
|
||||
@@ -6,7 +6,13 @@ import * as LucideIcons from 'lucide-react';
|
||||
|
||||
export function SettingsPage() {
|
||||
const { t } = useTranslation();
|
||||
const pluginSettingsPages = usePluginStore(s => s.getAllSettingsPages());
|
||||
const manifests = usePluginStore(s => s.manifests);
|
||||
const pluginSettingsPages = useMemo(
|
||||
() => manifests
|
||||
.flatMap((m) => m.settings_pages)
|
||||
.sort((a, b) => a.order - b.order),
|
||||
[manifests]
|
||||
);
|
||||
|
||||
const hardcodedNavItems = [
|
||||
{ to: '/settings/system', label: t('systemSettings.title'), icon: '\u2699\ufe0f' },
|
||||
|
||||
Reference in New Issue
Block a user