From da0d5495d05352e3e316dcf0a6725d5f1a34c9b9 Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 20 Mar 2026 21:54:56 +0100 Subject: [PATCH] fix(persons): prevent stale navigation from clobbering focused search input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The persons list search input used value={data.q || ''} bound directly to server data, so every navigation completion would reset it to the URL value mid-typing, dropping keystrokes just like issue #34 on the home page. Apply the same focus-guard fix: introduce local `q` state, a `qFocused` flag, and a guarded $effect that only syncs URL → state when the input is not focused. Adds a regression test matching the home-page pattern. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/routes/persons/+page.svelte | 18 +++-- .../src/routes/persons/page.svelte.spec.ts | 70 +++++++++++++++++++ 2 files changed, 84 insertions(+), 4 deletions(-) create mode 100644 frontend/src/routes/persons/page.svelte.spec.ts diff --git a/frontend/src/routes/persons/+page.svelte b/frontend/src/routes/persons/+page.svelte index 919440c6..4a389e23 100644 --- a/frontend/src/routes/persons/+page.svelte +++ b/frontend/src/routes/persons/+page.svelte @@ -1,16 +1,24 @@ @@ -49,8 +57,10 @@ function handleSearch(e: Event) { id="search" type="text" placeholder={m.persons_search_placeholder()} - value={data.q || ''} + bind:value={q} oninput={handleSearch} + onfocus={() => (qFocused = true)} + onblur={() => (qFocused = false)} class="block w-full rounded-sm border border-gray-300 bg-white py-2.5 pr-10 pl-4 font-sans text-sm text-brand-navy placeholder-gray-400 shadow-sm focus:border-brand-navy focus:ring-1 focus:ring-brand-navy focus:outline-none" />
new Promise((r) => setTimeout(r, 0)); + +vi.mock('$app/navigation', () => ({ goto: vi.fn() })); + +const makePerson = (overrides = {}) => ({ + id: '1', + firstName: 'Max', + lastName: 'Mustermann', + ...overrides +}); + +const emptyData = { user: undefined, canWrite: true, q: '', persons: [] }; +const dataWithPersons = { ...emptyData, persons: [makePerson()] }; + +// ─── Rendering ──────────────────────────────────────────────────────────────── + +describe('Persons page – rendering', () => { + it('renders the search input', async () => { + render(Page, { data: emptyData }); + await expect.element(page.getByRole('textbox')).toBeInTheDocument(); + }); + + it('pre-fills the search input from data.q', async () => { + render(Page, { data: { ...emptyData, q: 'Müller' } }); + await expect.element(page.getByRole('textbox')).toHaveValue('Müller'); + }); + + it('shows empty state when no persons', async () => { + render(Page, { data: emptyData }); + await expect.element(page.getByText('Keine Personen gefunden')).toBeInTheDocument(); + }); + + it('renders person cards', async () => { + render(Page, { data: dataWithPersons }); + await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument(); + }); + + it('links person card to detail page', async () => { + render(Page, { data: dataWithPersons }); + await expect + .element(page.getByRole('link', { name: /Max Mustermann/ })) + .toHaveAttribute('href', '/persons/1'); + }); +}); + +// ─── Keystroke preservation (issue #34) ────────────────────────────────────── + +describe('Persons page – search input keystroke preservation', () => { + it('does not overwrite the search input while the user is focused and stale data arrives', async () => { + const { rerender } = render(Page, { data: emptyData }); + + const input = page.getByRole('textbox'); + + // User types "abc" — input is focused + await input.click(); + await input.fill('abc'); + + // Simulate a navigation completing with stale data (q='a') while the user is still typing + await rerender({ data: { ...emptyData, q: 'a' } }); + await tick(); + + // Input must still show what the user typed, not the stale URL value + await expect.element(input).toHaveValue('abc'); + }); +}); -- 2.49.1