diff --git a/CHANGELOG.md b/CHANGELOG.md index cd9ccb77f4..16797e93d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ _In development — bullets added per PR; finalized at release._ - **Onboarding**: add the missing `onboarding.tiers` step-title translation so the setup wizard no longer crashes with `MISSING_MESSAGE: onboarding.tiers` (#4698). - **deepseek-web**: fold `role:"tool"` results into the single-prompt transcript (`messagesToPrompt`) so tool outputs reach the model instead of being silently dropped when a follow-up turn omits the `tools[]` array (#4712). - **Dashboard**: remove the dead, unconditional `useLiveRequests()` call from `HomePageClient.tsx` — it crashed the `/home` page in production builds with `ReferenceError: useLiveRequests is not defined` (#4759, #4745) and opened the live-dashboard WebSocket even when Provider Topology was hidden (#4596). The live feed remains owned by the settings-gated `HomeProviderTopologySection`. +- **Providers dashboard**: dedupe provider nodes by id when adding a compatible provider (`upsertProviderNodeById`) so the same provider can no longer appear twice and no-op adds don't invalidate the compatible-provider memo (#4746). --- diff --git a/src/app/(dashboard)/dashboard/providers/page.tsx b/src/app/(dashboard)/dashboard/providers/page.tsx index 8a2567af1b..e0f56500cd 100644 --- a/src/app/(dashboard)/dashboard/providers/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/page.tsx @@ -22,6 +22,7 @@ import { filterConfiguredProviderEntries, shouldFilterProviderEntriesForDisplayMode, shouldShowFirstProviderHint, + upsertProviderNodeById, } from "./providerPageUtils"; import type { ProviderEntry } from "./providerPageUtils"; import { @@ -1745,7 +1746,7 @@ export default function ProvidersPage() { mode="openai" onClose={() => setShowAddCompatibleModal(false)} onCreated={(node) => { - setProviderNodes((prev) => [...prev, node]); + setProviderNodes((prev) => upsertProviderNodeById(prev, node)); setShowAddCompatibleModal(false); router.push(`/dashboard/providers/${node.id}`); }} @@ -1755,7 +1756,7 @@ export default function ProvidersPage() { mode="anthropic" onClose={() => setShowAddAnthropicCompatibleModal(false)} onCreated={(node) => { - setProviderNodes((prev) => [...prev, node]); + setProviderNodes((prev) => upsertProviderNodeById(prev, node)); setShowAddAnthropicCompatibleModal(false); router.push(`/dashboard/providers/${node.id}`); }} @@ -1767,7 +1768,7 @@ export default function ProvidersPage() { title={addCcCompatibleLabel} onClose={() => setShowAddCcCompatibleModal(false)} onCreated={(node) => { - setProviderNodes((prev) => [...prev, node]); + setProviderNodes((prev) => upsertProviderNodeById(prev, node)); setShowAddCcCompatibleModal(false); router.push(`/dashboard/providers/${node.id}`); }} diff --git a/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts b/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts index 32da435927..72184b1769 100644 --- a/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts +++ b/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts @@ -276,3 +276,24 @@ export function resolveDashboardProviderInfo( ): ResolvedProviderCatalogEntry | null { return resolveProviderCatalogEntry(providerId, options); } + +/** + * Append or replace a provider node by `id`, never appending a duplicate (#4746). + * + * The compatible-provider "add" modals previously did `setProviderNodes((prev) => [...prev, node])`, + * so adding the same provider twice (refresh-then-add, double-click, retry, or React StrictMode + * double-invocation in dev) left the same `id` in the array twice — surfacing duplicate cards and + * invalidating the `compatibleProviderGroups` memo on every no-op add. This upsert dedups by id: + * - new id → append a new array, + * - same id, deep-equal payload → return `prev` unchanged (stable identity ⇒ memo does not re-run), + * - same id, changed payload → replace in place. + */ +export function upsertProviderNodeById(prev: T[], node: T): T[] { + if (!node || node.id == null) return [...prev, node]; + const idx = prev.findIndex((p) => p?.id === node.id); + if (idx === -1) return [...prev, node]; + if (JSON.stringify(prev[idx]) === JSON.stringify(node)) return prev; + const next = prev.slice(); + next[idx] = node; + return next; +} diff --git a/tests/unit/provider-node-dedup-4746.test.ts b/tests/unit/provider-node-dedup-4746.test.ts new file mode 100644 index 0000000000..18d8d0daee --- /dev/null +++ b/tests/unit/provider-node-dedup-4746.test.ts @@ -0,0 +1,35 @@ +// #4746 — the compatible-provider "add" modals appended provider nodes with +// `setProviderNodes((prev) => [...prev, node])`, so the same provider id could land in the +// array more than once (refresh-then-add, double-click, retry, StrictMode double-invocation), +// producing duplicate cards and invalidating the compatibleProviderGroups memo on no-op adds. +// upsertProviderNodeById dedups by id and keeps array identity stable for no-op adds. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { upsertProviderNodeById } from "../../src/app/(dashboard)/dashboard/providers/providerPageUtils.ts"; + +test("appends a node with a new id (#4746)", () => { + const prev = [{ id: "a", name: "A" }]; + const next = upsertProviderNodeById(prev, { id: "b", name: "B" }); + assert.deepEqual(next.map((n) => n.id), ["a", "b"]); +}); + +test("does not append a duplicate id — same identical payload returns prev unchanged (#4746)", () => { + const prev = [{ id: "a", name: "A" }]; + const next = upsertProviderNodeById(prev, { id: "a", name: "A" }); + assert.equal(next.length, 1); + assert.equal(next, prev, "no-op add must keep the same array reference (memo stability)"); +}); + +test("replaces an existing id when the payload changed (#4746)", () => { + const prev = [{ id: "a", name: "A" }, { id: "b", name: "B" }]; + const next = upsertProviderNodeById(prev, { id: "a", name: "A2" }); + assert.equal(next.length, 2); + assert.equal(next.find((n) => n.id === "a")?.name, "A2"); + assert.notEqual(next, prev); +}); + +test("appends when id is missing/null (cannot dedup) (#4746)", () => { + const prev = [{ id: "a" }]; + const next = upsertProviderNodeById(prev, { id: null } as { id: string | null }); + assert.equal(next.length, 2); +});