From 77d2cbfab8737913464eec3bdf0a61b9a8db9765 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Fri, 24 Apr 2026 14:47:17 +0800 Subject: [PATCH] fix: Prevent plugin detail hook mismatch during reload Plugin actions refresh the selected plugin detail after mutating enabled state. The detail component can enter its loading branch while a plugin is still selected, so every hook must run before that early return. Constraint: React hook order must stay identical across the detail and loading render paths. Rejected: Clear the selected plugin before reload | would hide the current context and add UI churn during short refreshes. Confidence: high Scope-risk: narrow Directive: Keep PluginDetail hooks before loading and empty-state returns when adding detail-derived memoization. Tested: cd desktop && bun run test src/__tests__/pluginsSettings.test.tsx Tested: cd desktop && bun run lint Tested: cd desktop && bun run build Not-tested: Full desktop Vitest suite has pre-existing locale assertion failures unrelated to this plugin fix. --- .../src/__tests__/pluginsSettings.test.tsx | 55 ++++++++++++++++++- .../src/components/plugins/PluginDetail.tsx | 17 +++--- 2 files changed, 63 insertions(+), 9 deletions(-) diff --git a/desktop/src/__tests__/pluginsSettings.test.tsx b/desktop/src/__tests__/pluginsSettings.test.tsx index 3a1e9d7a..11e20e49 100644 --- a/desktop/src/__tests__/pluginsSettings.test.tsx +++ b/desktop/src/__tests__/pluginsSettings.test.tsx @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { fireEvent, render, screen } from '@testing-library/react' +import { act, fireEvent, render, screen } from '@testing-library/react' import '@testing-library/jest-dom' import { Settings } from '../pages/Settings' @@ -338,6 +338,59 @@ describe('Settings > Plugins tab', () => { expect(screen.getByText('Uninstall')).toBeInTheDocument() }) + it('keeps plugin detail hook order stable while the selected plugin reloads', () => { + usePluginStore.setState({ + selectedPlugin: { + id: 'github@claude-plugins-official', + name: 'github', + marketplace: 'claude-plugins-official', + scope: 'user', + enabled: false, + hasErrors: false, + isBuiltin: false, + description: 'GitHub integration', + componentCounts: { + commands: 1, + agents: 0, + skills: 0, + hooks: 0, + mcpServers: 0, + lspServers: 0, + }, + capabilities: { + commands: ['review-pr'], + agents: [], + skills: [], + hooks: [], + mcpServers: [], + lspServers: [], + }, + commandEntries: [ + { + name: 'review-pr', + description: 'Review the current pull request.', + }, + ], + agentEntries: [], + hookEntries: [], + skillEntries: [], + mcpServerEntries: [], + errors: [], + }, + }) + + const { container } = render() + switchToPluginsTab() + + expect(screen.getByText('GitHub integration')).toBeInTheDocument() + + act(() => { + usePluginStore.setState({ isDetailLoading: true }) + }) + + expect(container.querySelector('.animate-spin')).toBeInTheDocument() + }) + it('navigates plugin skills into the shared Skills page flow', () => { usePluginStore.setState({ selectedPlugin: { diff --git a/desktop/src/components/plugins/PluginDetail.tsx b/desktop/src/components/plugins/PluginDetail.tsx index ab91091f..14c0d897 100644 --- a/desktop/src/components/plugins/PluginDetail.tsx +++ b/desktop/src/components/plugins/PluginDetail.tsx @@ -42,6 +42,15 @@ export function PluginDetail() { const activeSession = sessions.find((session) => session.id === activeSessionId) const currentWorkDir = activeSession?.workDir || undefined + const otherCapabilityItems = useMemo( + () => + CAPABILITY_ORDER.map((key) => ({ + key, + items: selectedPlugin?.capabilities[key] ?? [], + })), + [selectedPlugin], + ) + if (isDetailLoading) { return (
@@ -54,14 +63,6 @@ export function PluginDetail() { const canMutate = selectedPlugin.scope !== 'managed' && selectedPlugin.scope !== 'builtin' const canNavigateSharedCapabilities = selectedPlugin.enabled - const otherCapabilityItems = useMemo( - () => - CAPABILITY_ORDER.map((key) => ({ - key, - items: selectedPlugin.capabilities[key], - })), - [selectedPlugin], - ) const runAction = async (key: string, fn: () => Promise) => { setActionKey(key)