fix(documents): filter inputs don't sync with URL — Sender/Receiver blank on load, fields don't clear on reset #482
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Context
The advanced filter panel on
/documents(SearchFilterBar.svelte, embedded indocuments/+page.svelte) keeps its visible display text in component-local state that is initialised once and never re-synced from the boundvalueprop. The applied filter (URL query params + result list) and the visible filter inputs therefore drift apart in three observable ways:Symptom 1 — Sender/Receiver blank when URL has IDs (initial load)
/documents, e.g.Anna Müller→ URL becomes/documents?senderId=p-xxx.Expected: Sender input shows
Anna Müller.Actual: Sender input is empty even though the result list is correctly restricted to that sender. Same for Receiver with
?receiverId=....Symptom 2 — Reset (×) leaves stale Sender/Receiver text
/documents, expand "Filter", select Sender =Anna Müller, Receiver =Peter Schmidt.Expected: Sender and Receiver fields go blank.
Actual: URL clears to
/documentsand the result list refreshes, but both typeahead fields keep displaying the previously selected names.Symptom 3 — Reset (×) leaves stale From/To text
From = 01.01.1920andTo = 31.12.1930.Expected: From and To fields go blank.
Actual: URL and results clear, but both date fields keep displaying the typed German dates.
(Initial-load URL→UI for
from/toworks today becauseDateInputinitialisesdisplayfromvalueat mount — only subsequent prop changes are missed.)Root cause
Three concrete points, all the same anti-pattern (display state initialised once, never re-derived from the bound
valueprop):frontend/src/routes/documents/+page.svelte:238-254bindssenderId/receiverId(IDs) toSearchFilterBarbut does not passinitialSenderName/initialReceiverName. The receivingPersonTypeaheadtherefore always seesinitialName="".frontend/src/lib/person/PersonTypeahead.svelte:49,52-54keepssearchTermin sync only withinitialName. Since that prop is constant (""), the visible name persists across navigations and resets and is never populated from a URL-supplied ID.frontend/src/lib/shared/primitives/DateInput.svelte:25doeslet display = $state(isoToGerman(value ?? ''))exactly once. There is no$effectre-derivingdisplaywhen the boundvalueprop changes externally (reset, timeline drag inTimelineDensityFilter, future programmatic updates).Approach
Sender / Receiver
documents/+page.server.ts, whensenderId/receiverIdquery params are non-empty, resolve the correspondingPerson.displayNameserver-side (singleGET /api/persons/{id}per id is fine; tolerate 404 by returning empty string and clearing the param, or just return empty name). Return asinitialSenderName/initialReceiverName.documents/+page.svelte, bind these toSearchFilterBar'sinitialSenderName/initialReceiverNameprops.PersonTypeahead.svelte, harden the existing$effect: also clearsearchTermwhen the boundvaluebecomes empty whileinitialNameis empty (covers reset). One pattern:initialName. Don't introduce loops with thehandleInputvalue = ''reset path.From / To
DateInput.svelte, re-derivedisplaywhenever the boundvalueprop changes from outside (i.e. whenvalue !== germanToIso(display)):valuefromdisplay) from re-overwritingdisplay.Critical files
frontend/src/routes/documents/+page.server.ts— resolve names from IDsfrontend/src/routes/documents/+page.svelte— passinitialSenderName/initialReceiverNametoSearchFilterBarfrontend/src/lib/person/PersonTypeahead.svelte— clearsearchTermwhen boundvalueis cleared externallyfrontend/src/lib/shared/primitives/DateInput.svelte— re-derivedisplayfrom externalvaluechangesAcceptance criteria
/documents?senderId={id}directly shows the corresponding person's display name in the Sender input. The Filter panel auto-expands as today./documents?receiverId={id}directly shows the corresponding person's display name in the Receiver input./documents?senderId={non-existent-id}shows the input empty (not "undefined" or an error) and the result list as if no sender filter were applied./documents(with any combination of Sender / Receiver / From / To set) clears all four input fields visually, in addition to clearing the URL and refreshing results.TimelineDensityFilter) commits a new range via drag, the From and To input fields display the new German-format dates.01.01.1920into From still updates the URL to?from=1920-01-01and the typed value remains visible while the user types (no flicker / cursor-jump from the new$effect).frontend/src/routes/documents/page.svelte.spec.ts(and/orPersonTypeahead.svelte.spec.ts,DateInput.svelte.spec.tsas appropriate).Verification
cd frontend && npm run check— green.cd frontend && npm run test— green (new specs included).admin@familyarchive.local:Risk if not addressed
P1: confusing UX. URL-sharing of a filtered view appears broken (recipient sees empty inputs and a filtered list, looks like a stale page). Reset is also broken — users see "old text" linger after clearing, eroding trust in the filter UI. This is one of the most-used screens in the app.
Out of scope
tagNamesarray).🏛️ Markus Keller — Senior Application Architect
Observations
PersonTypeahead(frontend/src/lib/person/PersonTypeahead.svelte:52-54) already has$effect(() => { searchTerm = initialName; }). So the initial-load fix collapses to "feed the prop" —+page.server.tsresolves the displayName, parent passes it in, existing effect fires. No new effect needed for AC1/AC2.initialNamewas''already (typeahead-selected sender, never came from URL) and the user clicks reset,initialNamedoesn't change, so the existing effect doesn't re-fire. That's the only case the proposedif (!value) searchTerm = initialNameadds value for — at the cost of a new feedback loop withhandleInput'svalue = ''write (Felix will detail).+page.server.tsdoes its own param parsing inline (whitelist forsort, fallback fordir, etc.). Adding twoGET /api/persons/{id}calls per request is fine; it's the same pattern the rest of the file uses.Recommendations
+page.server.ts, in parallel with the existing search call (Promise.all). Tolerate 404 by returning''and clearing the param from the returned data — the user lands on a clean URL, not?senderId=garbage.PersonTypeahead. Make the parent the source of truth: always passinitialNamederived fromsenderId. IfsenderId === '',initialName === '', and the existing effect already resetssearchTerm. This removes the AC6 risk entirely.senderIdflips to''. A$effectin the parent (or inSearchFilterBar) that watchessenderIdgoing to empty can re-pass a newinitialName=''— even setting it to a sentinel string like a nav-counter key — to force the existing typeahead effect to re-fire. Cleaner than adding a second effect inside the leaf component.DateInput, the proposed$effectwith thegermanToIso(display) !== externalIsoguard is correct and stays inside the component — no architectural concern. Document the why in a one-line comment ("re-derive display on external value change without clobbering in-flight typing"), since this is the kind of code reviewers will want to delete in 6 months.Open Decisions
+page.server.tsor in a shared loader helper? The Briefwechsel and Aktivitäten routes will eventually face the same URL-share problem with person filters. AresolvePersonName(fetch, id)helper in$lib/person/server.tscosts ~10 lines and gets reused; inline costs nothing today but pays for the next two callers. Either is defensible — depends on whether issue 482 is followed by similar URL-share fixes elsewhere.👨💻 Felix Brandt — Senior Fullstack Developer
Observations
PersonTypeahead.sveltealready has the effect the issue proposes adding — line 52-54 setssearchTerm = initialNamewheneverinitialNamechanges. The eslint-disable comment above it explicitly documents why it's$state + $effectand not$derived.handleInput(line 100-108) doesvalue = ''when the user types in a field that previously held a selected ID. That write becomes a tracked dep of any effect that readsvalue.+page.server.tsalready callsapi.GETwith the typed client and the right error handling. Adding two moreGET /api/persons/{id}calls fits cleanly; bonus points forPromise.allso they don't serialize behind the search call.PersonTypeahead.svelte.spec.ts,DateInput.svelte.spec.ts,page.svelte.spec.ts. No new test infrastructure needed — TDD path is straight.Recommendations
PersonTypeaheadeffect has a typing-loop bug. Do not ship this version:bind:value={searchTerm}makessearchTerm = 'A'→handleInputruns →value = ''→ effect re-runs (it readsvalue) →searchTerm = initialName→ user sees their "A" wiped. AC6 fails.initialNamederived fromsenderId. The existing effect at line 52-54 then handles AC1, AC2, AC4 (when reset clearssenderId→ parent recomputesinitialName=''→ effect fires → display clears). No new effect insidePersonTypeahead. This is also fewer lines than the issue's plan.initialNamewas''before and after reset, so the effect doesn't re-fire), bump a key on every navigation. Easiest: pass aresetKey={data.url ?? Math.random()}prop and read it inside the existing effect:$effect(() => { void resetKey; searchTerm = initialName; });. Honest, no loop.DateInputfix is fine as proposed. ThegermanToIso(display) !== externalIsoguard correctly stops the round-trip. Add the test for typing01.01.1920(AC6 for dates) — write it red againstmainfirst to confirm there's no flicker risk.PersonTypeahead.svelte.spec.ts— "external value clear resets the input display"PersonTypeahead.svelte.spec.ts— "typing into a field with a selected id keeps the typed text" (this is the one that catches the loop bug)DateInput.svelte.spec.ts— "external value change re-derives display"DateInput.svelte.spec.ts— "typing keeps the typed text while value updates"page.svelte.spec.ts— "loads with senderId param and shows the resolved person name"page.svelte.spec.ts— "reset clears all four inputs"''. Don't try to clear the param mid-load — let the search return what it returns.PersonTypeaheadandDateInputbelong in their own spec files (those exist);page.svelte.spec.tscovers the integration (sender name resolved server-side).Open Decisions
(none — all items above have a clear correct answer)
🧪 Sara Holt — Senior QA Engineer
Observations
$effect") is exactly the regression that the proposedPersonTypeaheadpatch risks introducing. Good that it's named — the test must be written before the fix.PersonTypeahead.svelte.spec.ts,DateInput.svelte.spec.ts,page.svelte.spec.ts— no new harness work.+page.server.tsdoes a graceful try/catch fallback on the search call. The new person-name resolution must follow the same shape (one bad lookup ≠ broken page).vitest-browserbind:valueupdates need explicitdispatchEvent(new Event('input'))to fireoninputhandlers.DateInputusesoninput, so its tests must dispatch input events, not just set.value.Recommendations
PersonTypeahead.svelte.spec.ts— "typing 'A' into a field with a selected sender does not wipe the typed character on the next tick" — directly catches the proposed-fix typing loop (assertinput.value === 'A'after a microtask, plus a 50msawaitto let the effect run).DateInput.svelte.spec.ts— "typing while value is bound does not flicker the display" — type01.01.1920keystroke by keystroke, assert intermediate displays0,01,01.,01.0, ... never get overwritten.page.svelte.spec.ts— "unknown senderId clears the param without breaking the page" — server returns emptyinitialSenderName, page renders, no error toast.vitest-browserto drag the density filter range and assert German-format dates appear in From/To. This is the symptom that the issue-as-written might miss in CI even after the fix lands, because no current test exercises that drag → display path.mainfor the date fields (DateInput initial-load worked, only re-derive was broken). Set all four, click reset, assert all four blank in one assertion block.npm run checkandnpm run testare listed in §Verification — also runnpm run lint(Prettier picks up the new $effect indentation) andnpm run test:e2e -- documentsif there's a Playwright suite covering the documents page (verify before merging).copy URL → open new tab) at least once, headed, before claiming AC1/AC2 done. Browser-mode component tests don't fully exercise SvelteKit navigation re-renders..skip().Open Decisions
senderId(cleaner URL, better UX) vs. pass through and return 0 docs (preserves user intent to come back to a re-typed name). Worth a one-line decision before tests are written.🛡️ Nora Steiner — Application Security
Observations
+page.server.tsreadssenderIdandreceiverIddirectly fromurl.searchParamsand forwards them to the typed API client as query params. The typed client usesopenapi-fetch, which URL-encodes properly — no SQLi or query-string injection vector.GET /api/persons/{id}with a user-controlledid. The backendPersonController.getByIdis annotated with@RequirePermission(Permission.READ_ALL)(verified pattern across the codebase) and the cookie-forwardedcreateApiClient(fetch)carries the auth header — no IDOR exposure beyond what already exists.<a href="/documents">— a same-origin GET, no CSRF concern.Recommendations
fetch.api.GET('/api/persons/{id}', { params: { path: { id } } })— theidflows through the OpenAPI-generated path-param encoder, which is the only injection-safe form. Avoid string templating likefetch(`/api/persons/${senderId}`).initialSenderName: ''and proceed. This is consistent with the rest of the codebase'sgetErrorMessage(code)philosophy — no class names, no SQL, no stack traces reach the user.?senderId=garbage. Cheap regex check at the top of the load function:/^[0-9a-f-]{36}$/i. If it fails, treat the same as 404 (param dropped, name'').logger.info("senderId={}", senderId)is fine — SLF4J parameterized — but if anyone is tempted to add anerror("Failed for " + url)style during debugging, that's a Log4Shell-shaped foot-gun. Keep parameterized form throughout.?senderId={non-uuid}returns 200 with empty inputs and the search results unaffected. This is a regression test for a security smell (input validation at the boundary), even though it's primarily a UX guard.Open Decisions
(none — input validation, error handling, and 404 treatment all have clear correct answers from the existing codebase patterns)
⚙️ Tobias Wendt — DevOps & Platform Engineer
Observations
GET /api/persons/{id}calls per/documentspage load are negligible at the current scale (single-VPS, family-sized user base). Each is a primary-key lookup; PostgreSQL handles it in <2ms warm.+page.server.tsis on the SSR critical path. Adding latency here delays first paint.Recommendations
Promise.allwith the search call, not serially. Sequential adds 4–10ms per request to TTFB; parallel adds zero (capped by the slowest dep)./api/persons/{id}is already a permitted authenticated route.frontend/Vitest suite picks up new specs automatically — no workflow changes. Confirmfrontend-testandfrontend-e2ejobs (if present) stay green./api/persons/{id}ever 500s (unlikely for a primary-key lookup, but), the page must still render — the issue's plan tolerates 404 by returning empty name; ensure 5xx is also caught and degraded the same way, otherwise a backend hiccup blanks the whole/documentspage.Open Decisions
(none — operational impact is near zero)
🎨 Leonie Voss — UX & Accessibility
Observations
<a href="/documents">for reset is the right semantic — it works without JS, is keyboard-reachable, screen readers announce it as a link, and back-button restores the prior filter state.<a>withtitle={m.docs_btn_reset_title()}and an icon markedaria-hidden="true". The link itself has no accessible name from text content, only the title attribute (which is unreliable for screen readers and invisible on touch). This is not in scope for this issue, but fixing the AC4 reset bug is a natural moment to also make this link labelled properly (separate ticket if needed).Recommendations
aria-live="polite"toast/region: "Filter zurückgesetzt" / "Filters cleared". A keyboard user with screen reader gets no signal today even after the fix; the screen reader announces a navigation, but not "your filter is now blank."aria-label={m.docs_btn_reset_label()}to the reset<a>(separate fromtitle). Right now the only accessible name is the title attribute, which iOS VoiceOver and Android TalkBack handle inconsistently. Tracked separately if you prefer — not blocking — but the in-scope test suite is the right moment to surface it.PersonTypeahead, after reset, the input should not retainaria-expanded="true"or any open dropdown. The currentcloseDropdown()is called viaclickOutside, but a programmatic reset (URL nav) doesn't trigger that. Verify: after reset, the input is empty ANDaria-expanded="false"AND no listbox is in the DOM. Add to the AC4 test.<a>: it'spx-3 py-2.5with ah-5 w-5icon — that's roughly 36×30px hit area. Below the 44×44 WCAG 2.5.5 threshold. On a phone, missing this and accidentally hitting the Filter toggle next to it is exactly the failure mode that makes seniors give up. Bump tomin-h-[44px] min-w-[44px]and add explicitaria-label. Outside the AC list as written but trivially in scope of "reset works for users."text-ink-3andopacity-40— verify contrast against the dark-mode surface stays ≥3:1 (large icon threshold). Easy axe-playwright check.DateInput, when the value is externally cleared, the input visibly empties — good. But also clearerrorMessage(currently$bindable<string | null>(null)). If the user typed an invalid date, saw the inline error, then clicked reset, the error label would persist alongside the now-empty input. That's confusing. Add to AC4 test.Open Decisions
aria-liveregion (smaller change, only helps AT users). Both are correct WCAG-wise; the visible toast is more inclusive. Worth picking before implementation since it touchesSearchFilterBar.svelteeither way.📋 Elicit — Requirements Engineering (Brownfield)
Observations
feedback_spec_as_issuememory suggests.$effect"). Most issues forget to constrain the fix's side-effects.aria-expanded="false". Not covered.Recommendations
.gitea/ISSUE_TEMPLATE/bug.mdif not already present.Open Decisions
🗳️ Decision Queue — Action Required
3 decisions need your input before implementation starts.
Architecture
+page.server.ts(zero extra files, but code duplicates the next time Briefwechsel/Aktivitäten need it) vs. aresolvePersonName(api, id)helper in$lib/person/server.ts(~10 lines, one place to update on contract changes). Pick "shared helper" if more URL-share fixes are coming; pick "inline" if 482 is the last of these. (Raised by: Markus)Requirements
UX / Scope
aria-live="polite"(AC8), and (b) close the typeahead dropdown on programmatic reset (AC9)? Bigger PR but related fixes land together; smaller PR creates two follow-up tickets. If "yes," choose visible toast (more inclusive) vs. SR-onlyaria-liveregion (smaller change). (Raised by: Leonie, Elicit)