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.
This commit is contained in:
程序员阿江(Relakkes) 2026-04-24 14:47:17 +08:00
parent 6d40b2befa
commit 77d2cbfab8
2 changed files with 63 additions and 9 deletions

View File

@ -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(<Settings />)
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: {

View File

@ -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 (
<div className="flex justify-center py-12">
@ -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<string>) => {
setActionKey(key)