Clicking a provider card hitting back loses scroll position (#8349)

* clicking a provider card hitting back loses scroll position

* clean up code

* Rename some funcitons

* minor UX updates

* add null-guard in highlight()

* Add providerCardHandle tests

* add highlight tests. refactored code into separate utility functions

* Minor change to skip a firstElementChild call - use a ref to access the Link inside ProviderCard.
This commit is contained in:
Jay Ongg
2026-07-24 22:51:00 -07:00
committed by GitHub
parent 9a0e764459
commit 58ab8b1d2c
5 changed files with 420 additions and 12 deletions

View File

@@ -1,7 +1,7 @@
"use client";
import type { MouseEvent, ReactNode } from "react";
import { useState } from "react";
import { forwardRef, useCallback, useImperativeHandle, useRef, useState } from "react";
import Image from "next/image";
import Link from "next/link";
import { useTranslations } from "next-intl";
@@ -74,6 +74,7 @@ interface ProviderCardProps {
stats: ProviderStats;
authType?: string;
onToggle: (active: boolean) => void;
onCardClick?: (id: string) => void;
}
const DOT_COLORS: Record<string, string> = {
@@ -152,17 +153,51 @@ function getStatusDisplay(
return parts;
}
export default function ProviderCard({
providerId,
provider,
stats,
authType = "apikey",
onToggle,
}: ProviderCardProps) {
export type ProviderCardHandle = {
highlight: () => void;
getProviderId(): string;
scrollIntoView: (options?: ScrollIntoViewOptions) => void;
};
const ProviderCard = forwardRef<ProviderCardHandle, ProviderCardProps>(function ProviderCard(
{ providerId, provider, stats, authType = "apikey", onToggle, onCardClick },
ref
) {
const t = useTranslations("providers");
const tc = useTranslations("common");
const tp = useTranslations("miniPlayground");
const [testExpanded, setTestExpanded] = useState<boolean>(false);
const innerRef = useRef<HTMLDivElement>(null);
const linkElementRef = useRef<HTMLAnchorElement>(null);
useImperativeHandle(
ref,
() => ({
getProviderId() {
return providerId;
},
highlight() {
const el = innerRef.current;
if (!el) return;
linkElementRef.current?.focus();
const surface = linkElementRef.current?.firstElementChild;
surface?.animate(
[
{ backgroundColor: "rgba(59,130,246,0.22)" },
{ backgroundColor: "rgba(59,130,246,0.08)" },
{ backgroundColor: "transparent" },
],
{ duration: 3000, easing: "ease-in-out" }
);
},
scrollIntoView() {
const el = innerRef.current;
if (!el) return;
el.scrollIntoView({ behavior: "auto", block: "center" });
},
}),
[providerId, innerRef, linkElementRef]
);
// Show the Test button for LLM providers (when serviceKinds includes "llm"
// OR when the provider has no explicit serviceKinds but is a regular LLM provider
@@ -260,9 +295,18 @@ export default function ProviderCard({
onToggle(allDisabled);
};
const handleCardClick = useCallback(() => {
onCardClick?.(providerId);
}, [onCardClick, providerId]);
return (
<div className="flex flex-col h-full">
<Link href={`/dashboard/providers/${providerId}`} className="group flex-1 flex flex-col">
<div ref={innerRef} id={`provider-${providerId}`} className="flex flex-col h-full">
<Link
ref={linkElementRef}
href={`/dashboard/providers/${providerId}`}
className="group flex-1 flex flex-col focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary/60"
onClick={handleCardClick}
>
<Card
padding="xs"
className={`h-full flex flex-col hover:bg-black/5 dark:hover:bg-white/5 transition-colors cursor-pointer ${
@@ -422,7 +466,7 @@ export default function ProviderCard({
<Toggle
size="xs"
checked={!allDisabled}
onChange={() => {}}
onChange={undefined}
title={allDisabled ? t("enableProvider") : t("disableProvider")}
/>
</div>
@@ -461,4 +505,6 @@ export default function ProviderCard({
)}
</div>
);
}
});
export default ProviderCard;

View File

@@ -34,6 +34,7 @@ import {
loadProviderPageData,
} from "./providerPageUtils";
import type { ProviderEntry } from "./providerPageUtils";
import { recordProviderNavigation, resolveHighlightedCard } from "./providerPageHighlightUtils";
import {
readProviderDisplayModePreference,
shouldSyncProviderDisplayMode,
@@ -50,6 +51,7 @@ import { CategoryDot } from "./components/CategoryDot";
import { ImportProvidersFromFileModal } from "./components/ImportProvidersFromFileModal";
import NoAuthProvidersSection from "./components/NoAuthProvidersSection";
import ProviderCard from "./components/ProviderCard";
import type { ProviderCardHandle } from "./components/ProviderCard";
import ProviderCountBadge from "./components/ProviderCountBadge";
import ProviderSummaryCard from "./components/ProviderSummaryCard";
import {
@@ -205,6 +207,22 @@ export default function ProvidersPage() {
// #4240: media-category (serviceKind) filter — composes with activeCategory,
// search and configured-only. null = no serviceKind filter.
const [activeServiceKind, setActiveServiceKind] = useState<string | null>(null);
// a highlighted card is one that we should scroll into view and highlight upon navigation
const [highlightedProviderId, setHighlightedProviderId] = useState<string | null>(() => {
return history.state?.providerId ?? null;
});
const handleCardClick = useCallback((id: string) => {
recordProviderNavigation(id);
}, []);
const highlightedCardRef = useCallback(
(handle: ProviderCardHandle | null) => {
resolveHighlightedCard(handle, highlightedProviderId, () => setHighlightedProviderId(null));
},
[highlightedProviderId, setHighlightedProviderId]
);
const notify = useNotificationStore();
const sectionCategoryAliases: Record<string, string> = {
cloud: "cloudagent",
@@ -918,6 +936,9 @@ export default function ProvidersPage() {
onToggle={(active) =>
handleToggleProvider(entry.providerId, entry.toggleAuthType, active)
}
onCardClick={handleCardClick}
ref={highlightedCardRef}
/>
))}
</div>
@@ -1004,6 +1025,9 @@ export default function ProvidersPage() {
onToggle={(active) =>
handleToggleProvider(providerId, toggleAuthType, active)
}
onCardClick={handleCardClick}
ref={highlightedCardRef}
/>
)
)}
@@ -1078,6 +1102,9 @@ export default function ProvidersPage() {
onToggle={(active) =>
handleToggleProvider(providerId, toggleAuthType, active)
}
onCardClick={handleCardClick}
ref={highlightedCardRef}
/>
))}
</div>
@@ -1136,6 +1163,9 @@ export default function ProvidersPage() {
onToggle={(active) =>
handleToggleProvider(providerId, toggleAuthType, active)
}
onCardClick={handleCardClick}
ref={highlightedCardRef}
/>
)
)}
@@ -1184,6 +1214,9 @@ export default function ProvidersPage() {
stats={stats}
authType="web-cookie"
onToggle={(active) => handleToggleProvider(providerId, toggleAuthType, active)}
onCardClick={handleCardClick}
ref={highlightedCardRef}
/>
))}
</div>
@@ -1232,6 +1265,9 @@ export default function ProvidersPage() {
onToggle={(active) =>
handleToggleProvider(providerId, toggleAuthType, active)
}
onCardClick={handleCardClick}
ref={highlightedCardRef}
/>
)
)}
@@ -1285,6 +1321,9 @@ export default function ProvidersPage() {
onToggle={(active) =>
handleToggleProvider(providerId, toggleAuthType, active)
}
onCardClick={handleCardClick}
ref={highlightedCardRef}
/>
)
)}
@@ -1351,6 +1390,9 @@ export default function ProvidersPage() {
stats={stats}
authType="upstream-proxy"
onToggle={(active) => handleToggleProvider(providerId, toggleAuthType, active)}
onCardClick={handleCardClick}
ref={highlightedCardRef}
/>
))}
</div>
@@ -1383,6 +1425,9 @@ export default function ProvidersPage() {
onToggle={(active) =>
handleToggleProvider(providerId, toggleAuthType, active)
}
onCardClick={handleCardClick}
ref={highlightedCardRef}
/>
)
)}
@@ -1416,6 +1461,9 @@ export default function ProvidersPage() {
onToggle={(active) =>
handleToggleProvider(providerId, toggleAuthType, active)
}
onCardClick={handleCardClick}
ref={highlightedCardRef}
/>
)
)}
@@ -1449,6 +1497,9 @@ export default function ProvidersPage() {
onToggle={(active) =>
handleToggleProvider(providerId, toggleAuthType, active)
}
onCardClick={handleCardClick}
ref={highlightedCardRef}
/>
)
)}
@@ -1499,6 +1550,9 @@ export default function ProvidersPage() {
onToggle={(active) =>
handleToggleProvider(providerId, toggleAuthType, active)
}
onCardClick={handleCardClick}
ref={highlightedCardRef}
/>
)
)}
@@ -1546,6 +1600,9 @@ export default function ProvidersPage() {
stats={stats}
authType="local"
onToggle={(active) => handleToggleProvider(providerId, toggleAuthType, active)}
onCardClick={handleCardClick}
ref={highlightedCardRef}
/>
))}
</div>
@@ -1592,6 +1649,9 @@ export default function ProvidersPage() {
stats={stats}
authType="search"
onToggle={(active) => handleToggleProvider(providerId, toggleAuthType, active)}
onCardClick={handleCardClick}
ref={highlightedCardRef}
/>
))}
</div>
@@ -1624,6 +1684,9 @@ export default function ProvidersPage() {
onToggle={(active) =>
handleToggleProvider(providerId, toggleAuthType, active)
}
onCardClick={handleCardClick}
ref={highlightedCardRef}
/>
)
)}
@@ -1657,6 +1720,9 @@ export default function ProvidersPage() {
onToggle={(active) =>
handleToggleProvider(providerId, toggleAuthType, active)
}
onCardClick={handleCardClick}
ref={highlightedCardRef}
/>
)
)}
@@ -1704,6 +1770,9 @@ export default function ProvidersPage() {
stats={stats}
authType="audio"
onToggle={(active) => handleToggleProvider(providerId, toggleAuthType, active)}
onCardClick={handleCardClick}
ref={highlightedCardRef}
/>
))}
</div>
@@ -1736,6 +1805,9 @@ export default function ProvidersPage() {
onToggle={(active) =>
handleToggleProvider(providerId, toggleAuthType, active)
}
onCardClick={handleCardClick}
ref={highlightedCardRef}
/>
)
)}

View File

@@ -0,0 +1,27 @@
import type { ProviderCardHandle } from "./components/ProviderCard";
/**
* Called before navigating to a provider detail page. Persists the provider
* id in history.state so the list page can scroll to it on back-navigation.
*/
export function recordProviderNavigation(id: string) {
window.history.replaceState({ providerId: id }, "");
}
/**
* Ref callback for ProviderCard. When the rendered card's provider id
* matches the highlighted id, scrolls it into view and triggers the
* highlight animation. Always clears the highlighted id afterward so
* subsequent re-renders don't re-scroll.
*/
export function resolveHighlightedCard(
handle: ProviderCardHandle | null,
highlightedProviderId: string | null,
onAfterHighlight: () => void
) {
if (handle?.getProviderId() === highlightedProviderId) {
handle.scrollIntoView({ behavior: "auto", block: "center" });
handle.highlight();
}
onAfterHighlight();
}

View File

@@ -0,0 +1,134 @@
// @vitest-environment jsdom
/**
* ProviderCardHandle imperative API — highlight(), scrollIntoView(), getProviderId().
*
* NOTE on placement: see providerCardKimiPartnerAccent.test.tsx for rationale
* on keeping tests in tests/unit/ui/ (both vitest configs discover it here).
*/
import React from "react";
import { createRoot } from "react-dom/client";
import { act } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import ProviderCard, {
type ProviderCardHandle,
} from "@/app/(dashboard)/dashboard/providers/components/ProviderCard";
vi.mock("next-intl", () => ({ useTranslations: () => (k: string) => k }));
vi.mock("@/shared/components/ProviderTestSlideOver", () => ({ default: () => null }));
vi.mock("@/shared/components/ProviderIcon", () => ({ default: () => null }));
// jsdom does not implement scrollIntoView or animate
if (typeof Element.prototype.scrollIntoView === "undefined") {
Object.defineProperty(Element.prototype, "scrollIntoView", {
value: vi.fn(),
writable: true,
configurable: true,
});
}
if (typeof Element.prototype.animate === "undefined") {
Object.defineProperty(Element.prototype, "animate", {
value: () => ({ cancel: () => {}, finished: Promise.resolve() }),
writable: true,
configurable: true,
});
}
describe("ProviderCardHandle imperative API", () => {
let container: HTMLDivElement | null = null;
let handle: ProviderCardHandle | null = null;
afterEach(() => {
handle = null;
if (container) {
document.body.removeChild(container);
container = null;
}
});
const PROVIDER_ID = "openai";
const PROVIDER_NAME = "OpenAI";
function renderAndCapture() {
container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(
<ProviderCard
ref={(h) => {
handle = h;
}}
providerId={PROVIDER_ID}
provider={{ id: PROVIDER_ID, name: PROVIDER_NAME }}
stats={{ total: 1, connected: 1, error: 0, warning: 0 }}
authType="apikey"
onToggle={() => {}}
/>
);
});
}
it("getProviderId returns the providerId passed as a prop", () => {
renderAndCapture();
expect(handle).not.toBeNull();
expect(handle!.getProviderId()).toBe(PROVIDER_ID);
});
it("scrollIntoView calls Element.scrollIntoView on the wrapper div", () => {
renderAndCapture();
const spy = vi.spyOn(Element.prototype, "scrollIntoView");
act(() => {
handle!.scrollIntoView();
});
expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenCalledWith({ behavior: "auto", block: "center" });
spy.mockRestore();
});
it("highlight calls animate on the Card surface", () => {
renderAndCapture();
const spy = vi.spyOn(Element.prototype, "animate");
act(() => {
handle!.highlight();
});
expect(spy).toHaveBeenCalledTimes(1);
const keyframes = spy.mock.calls[0][0] as Keyframe[];
expect(keyframes).toHaveLength(3);
expect((keyframes[0] as Record<string, string>).backgroundColor).toBe("rgba(59,130,246,0.22)");
expect((keyframes[2] as Record<string, string>).backgroundColor).toBe("transparent");
spy.mockRestore();
});
it("highlight focuses the link element", () => {
renderAndCapture();
const focusSpy = vi.fn();
const link = container!.querySelector("a");
if (link) link.focus = focusSpy;
act(() => {
handle!.highlight();
});
expect(focusSpy).toHaveBeenCalledTimes(1);
});
it("getProviderId returns the correct id for a different provider", () => {
container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
let h: ProviderCardHandle | null = null;
act(() => {
root.render(
<ProviderCard
ref={(n) => {
h = n;
}}
providerId="kimi-coding"
provider={{ id: "kimi-coding", name: "Kimi Code CLI" }}
stats={{ total: 0, connected: 0, error: 0, warning: 0 }}
authType="oauth"
onToggle={() => {}}
/>
);
});
expect(h!.getProviderId()).toBe("kimi-coding");
});
});

View File

@@ -0,0 +1,129 @@
// @vitest-environment jsdom
/**
* Tests for the page-level highlighted-card matching/clearing logic
* extracted into providerPageHighlightUtils.ts.
*
* These import the real production functions — not copied code.
*/
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ProviderCardHandle } from "@/app/(dashboard)/dashboard/providers/components/ProviderCard";
import {
recordProviderNavigation,
resolveHighlightedCard,
} from "@/app/(dashboard)/dashboard/providers/providerPageHighlightUtils";
function createMockHandle(
id: string,
callbacks?: { onScroll?(): void; onHighlight?(): void }
): ProviderCardHandle {
return {
getProviderId() {
return id;
},
scrollIntoView() {
callbacks?.onScroll?.();
},
highlight() {
callbacks?.onHighlight?.();
},
};
}
describe("resolveHighlightedCard", () => {
it("calls scrollIntoView + highlight when handle matches highlighted id", () => {
const onScroll = vi.fn();
const onHighlight = vi.fn();
const onAfterHighlight = vi.fn();
const handle = createMockHandle("openai", { onScroll, onHighlight });
resolveHighlightedCard(handle, "openai", onAfterHighlight);
expect(onScroll).toHaveBeenCalledTimes(1);
expect(onHighlight).toHaveBeenCalledTimes(1);
expect(onAfterHighlight).toHaveBeenCalledTimes(1);
});
it("does NOT call scrollIntoView or highlight when ids do not match", () => {
const onScroll = vi.fn();
const onHighlight = vi.fn();
const onAfterHighlight = vi.fn();
const handle = createMockHandle("openai", { onScroll, onHighlight });
resolveHighlightedCard(handle, "anthropic", onAfterHighlight);
expect(onScroll).not.toHaveBeenCalled();
expect(onHighlight).not.toHaveBeenCalled();
expect(onAfterHighlight).toHaveBeenCalledTimes(1);
});
it("calls onAfterHighlight even when handle is null", () => {
const onAfterHighlight = vi.fn();
resolveHighlightedCard(null, "openai", onAfterHighlight);
expect(onAfterHighlight).toHaveBeenCalledTimes(1);
});
it("calls onAfterHighlight even when ids do not match", () => {
const onAfterHighlight = vi.fn();
const handle = createMockHandle("kimi-coding");
resolveHighlightedCard(handle, "openai", onAfterHighlight);
expect(onAfterHighlight).toHaveBeenCalledTimes(1);
});
it("only the matching handle triggers scroll + highlight among multiple", () => {
const calls: string[] = [];
const onAfterHighlight = vi.fn();
const handles = [
createMockHandle("openai", {
onScroll: () => calls.push("openai-scroll"),
onHighlight: () => calls.push("openai-highlight"),
}),
createMockHandle("anthropic", {
onScroll: () => calls.push("anthropic-scroll"),
onHighlight: () => calls.push("anthropic-highlight"),
}),
createMockHandle("cursor", {
onScroll: () => calls.push("cursor-scroll"),
onHighlight: () => calls.push("cursor-highlight"),
}),
];
resolveHighlightedCard(handles[0], "cursor", onAfterHighlight);
resolveHighlightedCard(handles[1], "cursor", onAfterHighlight);
resolveHighlightedCard(handles[2], "cursor", onAfterHighlight);
expect(calls).toEqual(["cursor-scroll", "cursor-highlight"]);
expect(onAfterHighlight).toHaveBeenCalledTimes(3);
});
});
describe("recordProviderNavigation", () => {
const originalReplaceState = window.history.replaceState;
afterEach(() => {
window.history.replaceState = originalReplaceState;
});
it("calls history.replaceState with the provider id", () => {
const replaceSpy = vi.fn();
window.history.replaceState = replaceSpy;
recordProviderNavigation("openai");
expect(replaceSpy).toHaveBeenCalledTimes(1);
expect(replaceSpy).toHaveBeenCalledWith({ providerId: "openai" }, "");
});
it("sets different provider ids on successive calls", () => {
const replaceSpy = vi.fn();
window.history.replaceState = replaceSpy;
recordProviderNavigation("openai");
recordProviderNavigation("anthropic");
recordProviderNavigation("kimi-coding");
expect(replaceSpy).toHaveBeenCalledTimes(3);
expect(replaceSpy.mock.calls[0][0]).toEqual({ providerId: "openai" });
expect(replaceSpy.mock.calls[1][0]).toEqual({ providerId: "anthropic" });
expect(replaceSpy.mock.calls[2][0]).toEqual({ providerId: "kimi-coding" });
});
});