From f8ac8efc323dfc850f13cd86ef0344cab66647c4 Mon Sep 17 00:00:00 2001 From: Junyong Park Date: Wed, 24 Jun 2026 00:15:11 +0900 Subject: [PATCH] fix: provider cache reset after settings import SettingsView keeps a local cachedState buffer so settings edits do not write through to the live extension state before Save. After importing settings, the import timestamp was used to bust that cache, but the effect could run again for the same import timestamp whenever extensionState changed identity. That allowed a later live state update to overwrite an in-progress provider edit with the previously saved provider, causing the provider UI to briefly switch and then revert. Track the handled settingsImportedAt value and only reset cachedState once per import event. Add a regression test for the imported-settings flow where Baseten is saved with an API key, the same import timestamp is replayed, and a subsequent DeepSeek provider edit is preserved and saved. --- .../src/components/settings/SettingsView.tsx | 10 +- .../SettingsView.change-detection.spec.tsx | 97 ++++++++++++++++++- 2 files changed, 102 insertions(+), 5 deletions(-) diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index 774f6a9d11..14d6fa4413 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -148,6 +148,7 @@ const SettingsView = forwardRef(({ onDone, t const contentRef = useRef(null) const prevApiConfigName = useRef(currentApiConfigName) + const handledSettingsImportedAt = useRef(undefined) const confirmDialogHandler = useRef<() => void>() const [cachedState, setCachedState] = useState(() => extensionState) @@ -233,10 +234,13 @@ const SettingsView = forwardRef(({ onDone, t // Bust the cache when settings are imported. useEffect(() => { - if (settingsImportedAt) { - setCachedState((prevCachedState) => ({ ...prevCachedState, ...extensionState })) - setChangeDetected(false) + if (!settingsImportedAt || handledSettingsImportedAt.current === settingsImportedAt) { + return } + + handledSettingsImportedAt.current = settingsImportedAt + setCachedState((prevCachedState) => ({ ...prevCachedState, ...extensionState })) + setChangeDetected(false) }, [settingsImportedAt, extensionState]) const setCachedStateField: SetCachedStateField = useCallback((field, value) => { diff --git a/webview-ui/src/components/settings/__tests__/SettingsView.change-detection.spec.tsx b/webview-ui/src/components/settings/__tests__/SettingsView.change-detection.spec.tsx index 2bbcc47b95..46e7c4881a 100644 --- a/webview-ui/src/components/settings/__tests__/SettingsView.change-detection.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/SettingsView.change-detection.spec.tsx @@ -4,12 +4,18 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query" import React from "react" // Mock vscode API -const mockPostMessage = vi.fn() +const mockPostMessage = vi.hoisted(() => vi.fn()) const mockVscode = { postMessage: mockPostMessage, } ;(global as any).acquireVsCodeApi = () => mockVscode +vi.mock("@src/utils/vscode", () => ({ + vscode: { + postMessage: mockPostMessage, + }, +})) + // Import the actual component import SettingsView from "../SettingsView" import { useExtensionState } from "@src/context/ExtensionStateContext" @@ -187,7 +193,24 @@ vi.mock("../ApiConfigManager", () => ({ })) vi.mock("../ApiOptions", () => ({ - default: () => null, + default: ({ apiConfiguration, setApiConfigurationField }: any) => ( +
+ {apiConfiguration.apiProvider} + setApiConfigurationField("basetenApiKey", event.target.value)} + /> + {["openrouter", "baseten", "deepseek"].map((provider) => ( + + ))} +
+ ), })) vi.mock("../AutoApproveSettings", () => ({ @@ -369,4 +392,74 @@ describe("SettingsView - Change Detection Fix", () => { expect(true).toBe(true) // Placeholder - the real test is the running system }) + + it("preserves a DeepSeek provider edit after saving Baseten when the same import timestamp replays", async () => { + const onDone = vi.fn() + let extensionState = createExtensionState({ + settingsImportedAt: 123, + apiConfiguration: { + apiProvider: "openai", + apiModelId: "gpt-4.1", + }, + }) + + ;(useExtensionState as any).mockImplementation(() => extensionState) + + const { rerender } = render( + + + , + ) + + await waitFor(() => { + expect(screen.getByTestId("provider-value")).toHaveTextContent("openai") + }) + + fireEvent.click(screen.getByTestId("set-provider-baseten")) + fireEvent.change(screen.getByTestId("baseten-api-key"), { target: { value: "test-baseten-key" } }) + expect(screen.getByTestId("provider-value")).toHaveTextContent("baseten") + + mockPostMessage.mockClear() + fireEvent.click(screen.getByTestId("save-button")) + expect(mockPostMessage).toHaveBeenCalledWith({ + type: "upsertApiConfiguration", + text: "default", + apiConfiguration: expect.objectContaining({ + apiProvider: "baseten", + basetenApiKey: "test-baseten-key", + }), + }) + + fireEvent.click(screen.getByTestId("set-provider-deepseek")) + expect(screen.getByTestId("provider-value")).toHaveTextContent("deepseek") + + extensionState = createExtensionState({ + settingsImportedAt: 123, + soundEnabled: true, + apiConfiguration: { + apiProvider: "baseten", + apiModelId: "zai-org/GLM-4.6", + basetenApiKey: "test-baseten-key", + }, + }) + + rerender( + + + , + ) + + expect(screen.getByTestId("provider-value")).toHaveTextContent("deepseek") + + mockPostMessage.mockClear() + fireEvent.click(screen.getByTestId("save-button")) + + expect(mockPostMessage).toHaveBeenCalledWith({ + type: "upsertApiConfiguration", + text: "default", + apiConfiguration: expect.objectContaining({ + apiProvider: "deepseek", + }), + }) + }) })