From 3b158b791d151204a3b5b8e771dc76debc49cfc6 Mon Sep 17 00:00:00 2001 From: yusuke8h Date: Tue, 14 Jul 2026 11:44:52 +0900 Subject: [PATCH 1/3] fix: ProviderOnboarding: Maximum update depth loop on clean first-run profile (#40) --- desktop/src/components/onboarding/ProviderOnboarding.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/desktop/src/components/onboarding/ProviderOnboarding.tsx b/desktop/src/components/onboarding/ProviderOnboarding.tsx index 81102de..21bc0ec 100644 --- a/desktop/src/components/onboarding/ProviderOnboarding.tsx +++ b/desktop/src/components/onboarding/ProviderOnboarding.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useEffect, useState } from 'react' import { useProviderStore } from '../../stores/providerStore' import { useSettingsStore } from '../../stores/settingsStore' import { Input } from '../shared/Input' @@ -12,8 +12,11 @@ export function ProviderOnboarding() { const [apiKey, setApiKey] = useState('') const [loading, setLoading] = useState(false) + useEffect(() => { + void fetchPresets() + }, [fetchPresets]) + if (presets.length === 0) { - fetchPresets() return (
From e335179f02a5170947ad1b30842393157acc0c37 Mon Sep 17 00:00:00 2001 From: yusuke8h Date: Tue, 14 Jul 2026 14:59:22 +0900 Subject: [PATCH 2/3] fix: harden onboarding preset loading --- .../onboarding/ProviderOnboarding.test.tsx | 72 +++++++++++++++++++ .../onboarding/ProviderOnboarding.tsx | 7 +- 2 files changed, 77 insertions(+), 2 deletions(-) create mode 100644 desktop/src/components/onboarding/ProviderOnboarding.test.tsx diff --git a/desktop/src/components/onboarding/ProviderOnboarding.test.tsx b/desktop/src/components/onboarding/ProviderOnboarding.test.tsx new file mode 100644 index 0000000..8688054 --- /dev/null +++ b/desktop/src/components/onboarding/ProviderOnboarding.test.tsx @@ -0,0 +1,72 @@ +import { StrictMode } from 'react' +import { render, screen } from '@testing-library/react' +import '@testing-library/jest-dom' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ProviderPreset } from '../../types/providerPreset' + +const providersApiMock = vi.hoisted(() => ({ + presets: vi.fn(), +})) + +vi.mock('../../api/providers', () => ({ + providersApi: providersApiMock, +})) + +import { useProviderStore } from '../../stores/providerStore' +import { ProviderOnboarding } from './ProviderOnboarding' + +const dreamfieldPreset: ProviderPreset = { + id: 'dreamfield', + name: 'DreamField', + baseUrl: 'https://example.invalid/api', + apiFormat: 'anthropic', + defaultModels: { + main: 'dreamfield-main', + haiku: '', + sonnet: '', + opus: '', + }, + needsApiKey: true, + websiteUrl: 'https://example.invalid', +} + +describe('ProviderOnboarding', () => { + beforeEach(() => { + vi.clearAllMocks() + useProviderStore.setState({ + providers: [], + activeId: null, + hasLoadedProviders: true, + presets: [], + isLoading: false, + isPresetsLoading: false, + error: null, + }) + }) + + it('loads presets once after mounting and advances past the loading screen', async () => { + providersApiMock.presets.mockResolvedValue({ presets: [dreamfieldPreset] }) + + render( + + + , + ) + + expect(await screen.findByRole('textbox', { name: /^API Key/ })).toBeInTheDocument() + expect(providersApiMock.presets).toHaveBeenCalledTimes(1) + }) + + it('does not refetch presets that are already available', () => { + useProviderStore.setState({ presets: [dreamfieldPreset] }) + + render( + + + , + ) + + expect(screen.getByRole('textbox', { name: /^API Key/ })).toBeInTheDocument() + expect(providersApiMock.presets).not.toHaveBeenCalled() + }) +}) diff --git a/desktop/src/components/onboarding/ProviderOnboarding.tsx b/desktop/src/components/onboarding/ProviderOnboarding.tsx index 21bc0ec..0325906 100644 --- a/desktop/src/components/onboarding/ProviderOnboarding.tsx +++ b/desktop/src/components/onboarding/ProviderOnboarding.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { useProviderStore } from '../../stores/providerStore' import { useSettingsStore } from '../../stores/settingsStore' import { Input } from '../shared/Input' @@ -11,10 +11,13 @@ export function ProviderOnboarding() { const fetchSettings = useSettingsStore((s) => s.fetchAll) const [apiKey, setApiKey] = useState('') const [loading, setLoading] = useState(false) + const requestedPresetsRef = useRef(false) useEffect(() => { + if (presets.length > 0 || requestedPresetsRef.current) return + requestedPresetsRef.current = true void fetchPresets() - }, [fetchPresets]) + }, [fetchPresets, presets.length]) if (presets.length === 0) { return ( From 95e29d3d28fc6ccfae7a5bd3f06008bb0c13d371 Mon Sep 17 00:00:00 2001 From: yusuke8h Date: Thu, 16 Jul 2026 07:34:36 +0900 Subject: [PATCH 3/3] fix: address review comment on #41 --- .../onboarding/ProviderOnboarding.test.tsx | 22 ++++++++++++++++++- .../onboarding/ProviderOnboarding.tsx | 12 +++++++++- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/desktop/src/components/onboarding/ProviderOnboarding.test.tsx b/desktop/src/components/onboarding/ProviderOnboarding.test.tsx index 8688054..95bed2a 100644 --- a/desktop/src/components/onboarding/ProviderOnboarding.test.tsx +++ b/desktop/src/components/onboarding/ProviderOnboarding.test.tsx @@ -1,5 +1,5 @@ import { StrictMode } from 'react' -import { render, screen } from '@testing-library/react' +import { fireEvent, render, screen } from '@testing-library/react' import '@testing-library/jest-dom' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { ProviderPreset } from '../../types/providerPreset' @@ -69,4 +69,24 @@ describe('ProviderOnboarding', () => { expect(screen.getByRole('textbox', { name: /^API Key/ })).toBeInTheDocument() expect(providersApiMock.presets).not.toHaveBeenCalled() }) + + it('shows an error with a retry button when preset loading fails, and recovers on retry', async () => { + providersApiMock.presets + .mockRejectedValueOnce(new Error('network down')) + .mockResolvedValueOnce({ presets: [dreamfieldPreset] }) + + render( + + + , + ) + + const retryButton = await screen.findByRole('button', { name: '重试' }) + expect(screen.getByText(/network down/)).toBeInTheDocument() + + fireEvent.click(retryButton) + + expect(await screen.findByRole('textbox', { name: /^API Key/ })).toBeInTheDocument() + expect(providersApiMock.presets).toHaveBeenCalledTimes(2) + }) }) diff --git a/desktop/src/components/onboarding/ProviderOnboarding.tsx b/desktop/src/components/onboarding/ProviderOnboarding.tsx index 0325906..0a900ef 100644 --- a/desktop/src/components/onboarding/ProviderOnboarding.tsx +++ b/desktop/src/components/onboarding/ProviderOnboarding.tsx @@ -6,7 +6,7 @@ import { Button } from '../shared/Button' import { DreamCoderIcon } from '../shared/DreamCoderIcon' export function ProviderOnboarding() { - const { presets, createProvider, activateProvider, fetchPresets } = useProviderStore() + const { presets, error, isPresetsLoading, createProvider, activateProvider, fetchPresets } = useProviderStore() const setOnboardingCompleted = useSettingsStore((s) => s.setOnboardingCompleted) const fetchSettings = useSettingsStore((s) => s.fetchAll) const [apiKey, setApiKey] = useState('') @@ -20,6 +20,16 @@ export function ProviderOnboarding() { }, [fetchPresets, presets.length]) if (presets.length === 0) { + // fetchPresets 失败只会写入 store 的 error,这里必须给出重试入口, + // 否则全新 profile 会永远停在加载动画上(issue #40 的遗留场景)。 + if (error && !isPresetsLoading) { + return ( +
+

预设加载失败:{error}

+ +
+ ) + } return (