mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-05-24 06:40:21 +08:00
fix(skills): prevent duplicate imports when import button is double-clicked (#2211)
Closes #2139 Two related defects let the installed-skills count balloon when users tap the import confirm button multiple times — either deliberately or because the button is still clickable while a slow import is in flight: - The confirm button only disabled itself while `selected.size === 0`, so it stayed clickable during a pending mutation. Each extra click triggered another `importFromApps` mutation. - `useImportSkillsFromApps` appended the server response to the installed cache without deduping, so re-firing the mutation stacked the same skills into the list again. Disable the confirm (and cancel) buttons while the mutation is pending — matching the `isRestoring` / `isDeleting` pattern already used by `RestoreSkillsDialog` — and merge success payloads by `InstalledSkill.id` so repeated results overwrite rather than accumulate. The merge is extracted as a pure `mergeImportedSkills` reducer to make the behaviour unit-testable and to short-circuit on an empty payload, returning the existing reference so React Query does not notify subscribers about a no-op cache update.
This commit is contained in:
@@ -454,6 +454,7 @@ const UnifiedSkillsPanel = React.forwardRef<
|
||||
{importDialogOpen && unmanagedSkills && (
|
||||
<ImportSkillsDialog
|
||||
skills={unmanagedSkills}
|
||||
isImporting={importMutation.isPending}
|
||||
onImport={handleImport}
|
||||
onClose={() => setImportDialogOpen(false)}
|
||||
/>
|
||||
@@ -600,6 +601,7 @@ interface ImportSkillsDialogProps {
|
||||
foundIn: string[];
|
||||
path: string;
|
||||
}>;
|
||||
isImporting: boolean;
|
||||
onImport: (imports: ImportSkillSelection[]) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
@@ -724,6 +726,7 @@ const RestoreSkillsDialog: React.FC<RestoreSkillsDialogProps> = ({
|
||||
|
||||
const ImportSkillsDialog: React.FC<ImportSkillsDialogProps> = ({
|
||||
skills,
|
||||
isImporting,
|
||||
onImport,
|
||||
onClose,
|
||||
}) => {
|
||||
@@ -846,10 +849,13 @@ const ImportSkillsDialog: React.FC<ImportSkillsDialogProps> = ({
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
<Button variant="outline" onClick={onClose} disabled={isImporting}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleImport} disabled={selected.size === 0}>
|
||||
<Button
|
||||
onClick={handleImport}
|
||||
disabled={selected.size === 0 || isImporting}
|
||||
>
|
||||
{t("skills.importSelected", { count: selected.size })}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { InstalledSkill } from "@/lib/api/skills";
|
||||
|
||||
/**
|
||||
* 合并本次导入结果到已安装缓存,按 id 去重。
|
||||
*
|
||||
* 同一技能重复出现时以新记录为准,避免 mutation 被重复触发时
|
||||
* 在 installed 列表中看到重复条目。imported 为空时返回原引用,
|
||||
* 让 React Query 跳过无谓的订阅者通知。
|
||||
*/
|
||||
export function mergeImportedSkills(
|
||||
existing: InstalledSkill[] | undefined,
|
||||
imported: InstalledSkill[],
|
||||
): InstalledSkill[] {
|
||||
if (!existing) return imported;
|
||||
if (imported.length === 0) return existing;
|
||||
const importedIds = new Set(imported.map((s) => s.id));
|
||||
const preserved = existing.filter((s) => !importedIds.has(s.id));
|
||||
return [...preserved, ...imported];
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
type SkillsShSearchResult,
|
||||
} from "@/lib/api/skills";
|
||||
import type { AppId } from "@/lib/api/types";
|
||||
import { mergeImportedSkills } from "@/hooks/useSkills.helpers";
|
||||
|
||||
/**
|
||||
* 查询所有已安装的 Skills
|
||||
@@ -208,10 +209,7 @@ export function useImportSkillsFromApps() {
|
||||
// 直接更新 installed 缓存
|
||||
queryClient.setQueryData<InstalledSkill[]>(
|
||||
["skills", "installed"],
|
||||
(oldData) => {
|
||||
if (!oldData) return importedSkills;
|
||||
return [...oldData, ...importedSkills];
|
||||
},
|
||||
(oldData) => mergeImportedSkills(oldData, importedSkills),
|
||||
);
|
||||
// 刷新 unmanaged 列表(已被导入的应该移除)
|
||||
queryClient.invalidateQueries({ queryKey: ["skills", "unmanaged"] });
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { mergeImportedSkills } from "@/hooks/useSkills.helpers";
|
||||
import type { InstalledSkill } from "@/lib/api/skills";
|
||||
|
||||
function makeSkill(overrides: Partial<InstalledSkill> = {}): InstalledSkill {
|
||||
return {
|
||||
id: "skill-a",
|
||||
name: "Skill A",
|
||||
directory: "skill-a",
|
||||
apps: {
|
||||
claude: true,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
opencode: false,
|
||||
openclaw: false,
|
||||
},
|
||||
installedAt: 0,
|
||||
updatedAt: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// Regression coverage for issue #2139: when a user double-clicks the import
|
||||
// button (or the mutation otherwise fires twice with the same payload), the
|
||||
// installed cache must not accumulate duplicate entries for the same skill.
|
||||
describe("mergeImportedSkills", () => {
|
||||
it("returns the imported list as-is when no cache exists yet", () => {
|
||||
const imported = [makeSkill()];
|
||||
expect(mergeImportedSkills(undefined, imported)).toEqual(imported);
|
||||
});
|
||||
|
||||
it("dedupes by id when the same skill is imported twice in a row", () => {
|
||||
const existing = [makeSkill()];
|
||||
const secondImport = [makeSkill()];
|
||||
const merged = mergeImportedSkills(existing, secondImport);
|
||||
expect(merged).toHaveLength(1);
|
||||
expect(merged[0]).toBe(secondImport[0]);
|
||||
});
|
||||
|
||||
it("replaces stale cache entries with fresh imports for the same id", () => {
|
||||
const stale = [makeSkill({ name: "Stale Name" })];
|
||||
const fresh = [makeSkill({ name: "Fresh Name" })];
|
||||
const merged = mergeImportedSkills(stale, fresh);
|
||||
expect(merged).toHaveLength(1);
|
||||
expect(merged[0].name).toBe("Fresh Name");
|
||||
});
|
||||
|
||||
it("returns the existing reference unchanged when the imported list is empty", () => {
|
||||
const existing = [makeSkill()];
|
||||
expect(mergeImportedSkills(existing, [])).toBe(existing);
|
||||
});
|
||||
|
||||
it("appends newly imported skills without dropping existing unrelated ones", () => {
|
||||
const existing = [makeSkill({ id: "skill-a", directory: "skill-a" })];
|
||||
const imported = [
|
||||
makeSkill({ id: "skill-b", directory: "skill-b", name: "Skill B" }),
|
||||
];
|
||||
const merged = mergeImportedSkills(existing, imported);
|
||||
expect(merged.map((s) => s.id).sort()).toEqual(["skill-a", "skill-b"]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user