fix(settings): keep unsaved provider-form edits across a background refetch

The provider detail form was the only one of the three detail forms created
without `resetOptions: { keepDirtyValues: true }`. Its seeding effect keys on
`data`, and settingsProviders is cache-and-network + replaceWithIncoming, so a
background refetch (list→detail race, or a provider* subscription) delivers a
fresh `data` reference mid-edit and the plain reset() silently wipes whatever
the user was typing. settings-prompt and template already guard this, the
latter with a comment naming the exact hazard.

Live-reproduced on the docker branch image: an edit typed during a delayed
refetch reverted to the server value. Regression test delivers a fresh data
reference after an edit and asserts it survives — it fails without the option.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Sergey Kozyrenko
2026-07-25 03:17:47 +07:00
co-authored by Claude Opus 4.8
parent e31c725cc0
commit 81f3d06ebd
2 changed files with 38 additions and 3 deletions
@@ -1,4 +1,4 @@
import { render, screen } from '@testing-library/react';
import { fireEvent, render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ProviderType } from '@/graphql/types';
@@ -6,7 +6,7 @@ import { routes } from '@/lib/routes';
const { navigate } = vi.hoisted(() => ({ navigate: vi.fn() }));
const state = vi.hoisted(() => ({ params: new URLSearchParams() }));
const state = vi.hoisted(() => ({ params: new URLSearchParams(), providerId: 'new' }));
const setSearch = (search: string) => {
state.params = new URLSearchParams(search);
@@ -37,6 +37,14 @@ const userDefined = [
type: ProviderType.Minimax,
updatedAt: '',
},
{
agents: {},
createdAt: '',
id: 'edit-1',
name: 'Persisted Name',
type: ProviderType.Anthropic,
updatedAt: '',
},
];
const settingsProviders = {
@@ -65,7 +73,7 @@ vi.mock('react-router-dom', async (importOriginal) => ({
...(await importOriginal<typeof import('react-router-dom')>()),
useBlocker: () => ({ proceed: undefined, reset: undefined, state: 'unblocked' }),
useNavigate: () => navigate,
useParams: () => ({ providerId: 'new' }),
useParams: () => ({ providerId: state.providerId }),
useSearchParams: () => [state.params, vi.fn()],
}));
@@ -94,6 +102,8 @@ import SettingsProvider from './settings-provider';
beforeEach(() => {
navigate.mockClear();
setSearch('');
state.providerId = 'new';
queryResult.data = { settingsProviders };
queryResult.error = undefined;
queryResult.loading = false;
});
@@ -148,4 +158,25 @@ describe('SettingsProvider create-form type guards', () => {
expect(screen.queryByText('Loading provider data...')).not.toBeInTheDocument();
expect(navigate).not.toHaveBeenCalled();
});
// The seeding effect re-runs on every settingsProviders refetch (a fresh `data` reference under
// cache-and-network); keepDirtyValues is what stops that reset from wiping an in-flight edit.
it('preserves an in-flight edit across a background refetch', () => {
state.providerId = 'edit-1';
const { rerender } = render(<SettingsProvider />);
const nameInput = screen.getByPlaceholderText('Enter provider name') as HTMLInputElement;
expect(nameInput.value).toBe('Persisted Name');
fireEvent.change(nameInput, { target: { value: 'My Unsaved Edit' } });
// A refetch delivers a new `data` object with the same server content, re-firing the effect.
queryResult.data = { settingsProviders };
rerender(<SettingsProvider />);
expect((screen.getByPlaceholderText('Enter provider name') as HTMLInputElement).value).toBe(
'My Unsaved Edit',
);
});
});
@@ -1203,6 +1203,10 @@ function SettingsProvider() {
name: undefined,
type: undefined,
},
// The seeding effect re-runs on every settingsProviders refetch (cache-and-network +
// replaceWithIncoming gives a fresh `data`); without this a background refetch landing
// mid-edit silently wipes the unsaved form. Matches settings-prompt and template.
resetOptions: { keepDirtyValues: true },
schema: formSchema,
});