Skip to content

Commit 63dec51

Browse files
fix(#689): provider cache reset after settings import (#726)
* 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. * test: cover settings import timestamp cache reset behavior Strengthen SettingsView regression coverage around settingsImportedAt. Verify that replaying the same import timestamp does not let the cache-busting effect overwrite an in-progress provider edit, and add the inverse case to prove a fresh import timestamp still refreshes cachedState from the latest extensionState apiConfiguration. Also assert that a fresh import clears the dirty state by disabling the Save button after pending edits are replaced by imported settings.
1 parent 7476c67 commit 63dec51

2 files changed

Lines changed: 158 additions & 6 deletions

File tree

webview-ui/src/components/settings/SettingsView.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
148148
const contentRef = useRef<HTMLDivElement | null>(null)
149149

150150
const prevApiConfigName = useRef(currentApiConfigName)
151+
const handledSettingsImportedAt = useRef<number | undefined>(undefined)
151152
const confirmDialogHandler = useRef<() => void>()
152153

153154
const [cachedState, setCachedState] = useState(() => extensionState)
@@ -233,10 +234,13 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
233234

234235
// Bust the cache when settings are imported.
235236
useEffect(() => {
236-
if (settingsImportedAt) {
237-
setCachedState((prevCachedState) => ({ ...prevCachedState, ...extensionState }))
238-
setChangeDetected(false)
237+
if (!settingsImportedAt || handledSettingsImportedAt.current === settingsImportedAt) {
238+
return
239239
}
240+
241+
handledSettingsImportedAt.current = settingsImportedAt
242+
setCachedState((prevCachedState) => ({ ...prevCachedState, ...extensionState }))
243+
setChangeDetected(false)
240244
}, [settingsImportedAt, extensionState])
241245

242246
const setCachedStateField: SetCachedStateField<keyof ExtensionStateContextType> = useCallback((field, value) => {

webview-ui/src/components/settings/__tests__/SettingsView.change-detection.spec.tsx

Lines changed: 151 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,21 @@
1-
import { render, screen, fireEvent, waitFor } from "@testing-library/react"
1+
import { act, render, screen, fireEvent, waitFor } from "@testing-library/react"
22
import { vi, describe, it, expect, beforeEach } from "vitest"
33
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
44
import React from "react"
55

66
// Mock vscode API
7-
const mockPostMessage = vi.fn()
7+
const mockPostMessage = vi.hoisted(() => vi.fn())
88
const mockVscode = {
99
postMessage: mockPostMessage,
1010
}
1111
;(global as any).acquireVsCodeApi = () => mockVscode
1212

13+
vi.mock("@src/utils/vscode", () => ({
14+
vscode: {
15+
postMessage: mockPostMessage,
16+
},
17+
}))
18+
1319
// Import the actual component
1420
import SettingsView from "../SettingsView"
1521
import { useExtensionState } from "@src/context/ExtensionStateContext"
@@ -187,7 +193,24 @@ vi.mock("../ApiConfigManager", () => ({
187193
}))
188194

189195
vi.mock("../ApiOptions", () => ({
190-
default: () => null,
196+
default: ({ apiConfiguration, setApiConfigurationField }: any) => (
197+
<div>
198+
<span data-testid="provider-value">{apiConfiguration.apiProvider}</span>
199+
<input
200+
data-testid="baseten-api-key"
201+
value={apiConfiguration.basetenApiKey ?? ""}
202+
onChange={(event) => setApiConfigurationField("basetenApiKey", event.target.value)}
203+
/>
204+
{["openrouter", "baseten", "deepseek"].map((provider) => (
205+
<button
206+
key={provider}
207+
data-testid={`set-provider-${provider}`}
208+
onClick={() => setApiConfigurationField("apiProvider", provider)}>
209+
{provider}
210+
</button>
211+
))}
212+
</div>
213+
),
191214
}))
192215

193216
vi.mock("../AutoApproveSettings", () => ({
@@ -369,4 +392,129 @@ describe("SettingsView - Change Detection Fix", () => {
369392

370393
expect(true).toBe(true) // Placeholder - the real test is the running system
371394
})
395+
396+
it("preserves a DeepSeek provider edit after saving Baseten when the same import timestamp replays", async () => {
397+
const onDone = vi.fn()
398+
let extensionState = createExtensionState({
399+
settingsImportedAt: 123,
400+
apiConfiguration: {
401+
apiProvider: "openai",
402+
apiModelId: "gpt-4.1",
403+
},
404+
})
405+
406+
;(useExtensionState as any).mockImplementation(() => extensionState)
407+
408+
const { rerender } = render(
409+
<QueryClientProvider client={queryClient}>
410+
<SettingsView onDone={onDone} />
411+
</QueryClientProvider>,
412+
)
413+
414+
await waitFor(() => {
415+
expect(screen.getByTestId("provider-value")).toHaveTextContent("openai")
416+
})
417+
418+
fireEvent.click(screen.getByTestId("set-provider-baseten"))
419+
fireEvent.change(screen.getByTestId("baseten-api-key"), { target: { value: "test-baseten-key" } })
420+
expect(screen.getByTestId("provider-value")).toHaveTextContent("baseten")
421+
422+
mockPostMessage.mockClear()
423+
fireEvent.click(screen.getByTestId("save-button"))
424+
expect(mockPostMessage).toHaveBeenCalledWith({
425+
type: "upsertApiConfiguration",
426+
text: "default",
427+
apiConfiguration: expect.objectContaining({
428+
apiProvider: "baseten",
429+
basetenApiKey: "test-baseten-key",
430+
}),
431+
})
432+
433+
fireEvent.click(screen.getByTestId("set-provider-deepseek"))
434+
expect(screen.getByTestId("provider-value")).toHaveTextContent("deepseek")
435+
436+
extensionState = createExtensionState({
437+
settingsImportedAt: 123,
438+
soundEnabled: true,
439+
apiConfiguration: {
440+
apiProvider: "baseten",
441+
apiModelId: "zai-org/GLM-4.6",
442+
basetenApiKey: "test-baseten-key",
443+
},
444+
})
445+
446+
rerender(
447+
<QueryClientProvider client={queryClient}>
448+
<SettingsView onDone={onDone} />
449+
</QueryClientProvider>,
450+
)
451+
452+
// Let the import cache-busting effect run. With the old implementation,
453+
// this would reset cachedState back to the replayed Baseten config.
454+
await act(async () => {
455+
await new Promise((resolve) => setTimeout(resolve, 0))
456+
})
457+
458+
expect(screen.getByTestId("provider-value")).toHaveTextContent("deepseek")
459+
460+
mockPostMessage.mockClear()
461+
fireEvent.click(screen.getByTestId("save-button"))
462+
463+
expect(mockPostMessage).toHaveBeenCalledWith({
464+
type: "upsertApiConfiguration",
465+
text: "default",
466+
apiConfiguration: expect.objectContaining({
467+
apiProvider: "deepseek",
468+
}),
469+
})
470+
})
471+
472+
it("resets cached provider state when a new import timestamp arrives", async () => {
473+
const onDone = vi.fn()
474+
let extensionState = createExtensionState({
475+
settingsImportedAt: 100,
476+
apiConfiguration: {
477+
apiProvider: "openai",
478+
apiModelId: "gpt-4.1",
479+
},
480+
})
481+
482+
;(useExtensionState as any).mockImplementation(() => extensionState)
483+
484+
const { rerender } = render(
485+
<QueryClientProvider client={queryClient}>
486+
<SettingsView onDone={onDone} />
487+
</QueryClientProvider>,
488+
)
489+
490+
await waitFor(() => {
491+
expect(screen.getByTestId("provider-value")).toHaveTextContent("openai")
492+
})
493+
494+
fireEvent.click(screen.getByTestId("set-provider-deepseek"))
495+
expect(screen.getByTestId("provider-value")).toHaveTextContent("deepseek")
496+
497+
extensionState = createExtensionState({
498+
settingsImportedAt: 101,
499+
apiConfiguration: {
500+
apiProvider: "baseten",
501+
apiModelId: "zai-org/GLM-4.6",
502+
basetenApiKey: "imported-baseten-key",
503+
},
504+
})
505+
506+
rerender(
507+
<QueryClientProvider client={queryClient}>
508+
<SettingsView onDone={onDone} />
509+
</QueryClientProvider>,
510+
)
511+
512+
await waitFor(() => {
513+
expect(screen.getByTestId("provider-value")).toHaveTextContent("baseten")
514+
})
515+
516+
await waitFor(() => {
517+
expect(screen.getByTestId("save-button")).toBeDisabled()
518+
})
519+
})
372520
})

0 commit comments

Comments
 (0)