From 271b52a7f4ed6f12bcf986883df4a5045d30079b Mon Sep 17 00:00:00 2001 From: dev_agent Date: Tue, 6 Oct 2026 10:50:26 -0400 Subject: [PATCH] Count only real edits as unsaved recipe search changes Before recipe search is set up the card suggests SearXNG and the deployment's URL, which differ from the unset saved values. That suggestion marked the card dirty, so the leave guard warned about unsaved changes the household never made. The leave guard now fires only when the draft differs from the loaded baseline, which includes the suggestion. Save stays enabled so the suggestion can still be stored. Co-Authored-By: Claude Opus 5.5 --- web/src/settings/AISettingsPage.test.tsx | 23 +++++++++++++++++++++++ web/src/settings/RecipeSearchCard.tsx | 12 ++++++++++-- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/web/src/settings/AISettingsPage.test.tsx b/web/src/settings/AISettingsPage.test.tsx index 903fabd..1ad7dbd 100644 --- a/web/src/settings/AISettingsPage.test.tsx +++ b/web/src/settings/AISettingsPage.test.tsx @@ -657,6 +657,29 @@ describe('Recipe search card', () => { expect(await screen.findByRole('dialog', { name: 'Leave without saving?' })).toBeInTheDocument() }) + it('does not count the suggested SearXNG URL as an unsaved change until it is edited', async () => { + stubAPI({ 'GET /api/v1/settings/recipe-search': () => jsonResponse(200, UNCONFIGURED) }) + await renderCard() + const unload = () => { + const event = new Event('beforeunload', { cancelable: true }) + act(() => void window.dispatchEvent(event)) + return event.defaultPrevented + } + expect(saveSearch()).toBeEnabled() + expect(unload()).toBe(false) + fireEvent.change(url(), { target: { value: 'https://search.example' } }) + expect(unload()).toBe(true) + fireEvent.change(url(), { target: { value: 'http://searxng:8080' } }) + expect(unload()).toBe(false) + }) + + it('leaves the untouched first-use card without asking', async () => { + stubAPI({ 'GET /api/v1/settings/recipe-search': () => jsonResponse(200, UNCONFIGURED) }) + await renderCard() + fireEvent.click(screen.getByRole('button', { name: 'Back to Settings' })) + expect(await screen.findByTestId('path')).toHaveTextContent('/settings') + }) + it('keeps 44 px tap targets', async () => { stubAPI({ 'GET /api/v1/settings/recipe-search': () => jsonResponse(200, BRAVE_SETTINGS) }) await renderCard() diff --git a/web/src/settings/RecipeSearchCard.tsx b/web/src/settings/RecipeSearchCard.tsx index 9d54064..7cf7daf 100644 --- a/web/src/settings/RecipeSearchCard.tsx +++ b/web/src/settings/RecipeSearchCard.tsx @@ -81,16 +81,24 @@ function patchOf(saved: RecipeSearchSettings, draft: Draft): RecipeSearchSetting return patch } +function sameDraft(a: Draft, b: Draft): boolean { + return a.provider === b.provider && a.base_url === b.base_url && a.api_key === b.api_key +} + function RecipeSearchForm({ saved, onDirtyChange }: { saved: RecipeSearchSettings } & RecipeSearchCardProps) { const [draft, setDraft] = useState(() => draftOf(saved)) const save = useSaveRecipeSearchSettings() const test = useTestRecipeSearch() const patch = patchOf(saved, draft) const dirty = Object.keys(patch).length > 0 + // Only the household's own edits are unsaved changes. Before recipe search + // is set up the untouched draft already differs from what is saved — it + // suggests SearXNG and the deployment's URL — but leaving it loses nothing. + const edited = dirty && !sameDraft(draft, draftOf(saved)) useEffect(() => { - onDirtyChange(dirty) - }, [dirty, onDirtyChange]) + onDirtyChange(edited) + }, [edited, onDirtyChange]) useEffect(() => () => onDirtyChange(false), [onDirtyChange]) // An edit clears the last save and test results: they describe the saved