diff --git a/changelog.d/fixes/9626-playground-errors.md b/changelog.d/fixes/9626-playground-errors.md new file mode 100644 index 0000000000..3bf1ee0343 --- /dev/null +++ b/changelog.d/fixes/9626-playground-errors.md @@ -0,0 +1 @@ +- fix(playground): surface provider model loading errors and offer retry (#9626) diff --git a/src/app/(dashboard)/dashboard/media-providers/components/LlmChatCard.tsx b/src/app/(dashboard)/dashboard/media-providers/components/LlmChatCard.tsx index 27a900c541..53700db68a 100644 --- a/src/app/(dashboard)/dashboard/media-providers/components/LlmChatCard.tsx +++ b/src/app/(dashboard)/dashboard/media-providers/components/LlmChatCard.tsx @@ -134,7 +134,7 @@ export function LlmChatCard({ }: Props) { const t = useTranslations("miniPlayground"); const { keys } = useApiKey(); - const { models } = useProviderModels(providerId); + const { models, loading, error, retry } = useProviderModels(providerId); const [internalSelectedKey, setInternalSelectedKey] = useState(""); const [internalModel, setInternalModel] = useState(initialModel ?? ""); @@ -392,15 +392,31 @@ export function LlmChatCard({ + {error && ( + + + {String(error)} + + + + )} {/* Key select */} {keys.length > 0 && ( diff --git a/src/app/(dashboard)/dashboard/providers/hooks/useProviderModels.ts b/src/app/(dashboard)/dashboard/providers/hooks/useProviderModels.ts index b2b2ec62d4..c3997f813d 100644 --- a/src/app/(dashboard)/dashboard/providers/hooks/useProviderModels.ts +++ b/src/app/(dashboard)/dashboard/providers/hooks/useProviderModels.ts @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useCallback, useRef } from "react"; export interface ProviderModel { id: string; @@ -18,6 +18,8 @@ interface UseProviderModelsResult { models: ProviderModel[]; loading: boolean; error: string | null; + /** Re-runs the model fetch for the current provider. Useful for a Retry action. */ + retry: () => void; } /** @@ -32,15 +34,14 @@ export function useProviderModels(providerId: string): UseProviderModelsResult { const [models, setModels] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + // Cancels any in-flight load (component unmount or a retry superseding the + // previous request) so a stale response never overwrites a newer one. + const cleanupRef = useRef<(() => void) | null>(null); - useEffect(() => { - if (!providerId) { - setLoading(false); - return; - } - + const load = useCallback(() => { + cleanupRef.current?.(); let cancelled = false; - const load = async () => { + const run = async () => { setLoading(true); setError(null); try { @@ -109,11 +110,33 @@ export function useProviderModels(providerId: string): UseProviderModelsResult { if (!cancelled) setLoading(false); } }; - void load(); - return () => { + void run(); + const cleanup = () => { cancelled = true; }; + cleanupRef.current = cleanup; + return cleanup; }, [providerId]); - return { models, loading, error }; + useEffect(() => { + if (!providerId) { + setLoading(false); + return; + } + return load(); + }, [providerId, load]); + + // Release the current in-flight cleanup on unmount so no state updates leak. + useEffect(() => { + return () => { + cleanupRef.current?.(); + }; + }, []); + + const retry = useCallback(() => { + if (!providerId) return; + load(); + }, [providerId, load]); + + return { models, loading, error, retry }; } diff --git a/tests/unit/repro-9626.test.ts b/tests/unit/repro-9626.test.ts new file mode 100644 index 0000000000..022bca6771 --- /dev/null +++ b/tests/unit/repro-9626.test.ts @@ -0,0 +1,62 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +const root = join(import.meta.dirname, "../.."); +const llmChatCardPath = + "src/app/(dashboard)/dashboard/media-providers/components/LlmChatCard.tsx"; +const src = readFileSync(join(root, llmChatCardPath), "utf8"); + +const DISABLED_ON_LOADING = /disabled\s*=\s*\{\s*loading\s*\}/; +const MODELS_LOADING_MARKER = /modelsLoading|Loading…|Loading\.\.\./; +const ERROR_BRANCH = /error\s*&&/; +const RETRY_ACTION = /onClick\s*=\s*\{[^}]*retry|retry[A-Za-z]*\s*\(\)|const\s+\[reload/i; +const NO_MODELS_AFTER_EMPTY = /modelOptions\.length\s*===?\s*0|models\.length\s*===?\s*0/; + +test("LlmChatCard destructures loading and error from useProviderModels (#9626)", () => { + const match = src.match(/const\s*\{\s*([^}]+)\s*\}\s*=\s*useProviderModels\(/); + assert.ok(match, "Expected to find a destructuring of useProviderModels"); + + const destructured = match[1]; + assert.ok( + destructured.includes("loading"), + "loading state must be destructured from useProviderModels" + ); + assert.ok(destructured.includes("error"), "error state must be destructured from useProviderModels"); +}); + +test("LlmChatCard disables the model selector while models are loading (#9626)", () => { + assert.ok( + DISABLED_ON_LOADING.test(src), + "The model