From bf68bc08f48908287b992ca81d9c34a9d628360f Mon Sep 17 00:00:00 2001 From: fabi Date: Tue, 14 Jul 2026 22:06:56 +0200 Subject: [PATCH] fix(feed): commit filter suggestions on click, not mousedown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `filter-search` spec was flaky (~3%: 5 failures in 180 runs), always as "element was detached from the DOM" while clicking a suggestion. It was not a test bug — it was reporting a real defect. `selectSuggestion` sets `showAutocomplete = false`, which unmounts the dropdown, so a suggestion button DESTROYS ITSELF when activated. It was wired to `onmousedown` — the first event of the click sequence — purely to beat the input's `onblur`, which would otherwise close the dropdown before a click could land. So whether the node survived to `mouseup` depended on whether Svelte's flush happened to fall between the two events. Under load it did, and the click was lost. That is also a real user-facing bug: because it committed on PRESS, sliding off a suggestion you didn't mean to hit still applied the filter, with no way to abort. Fixed the standard combobox way: preventDefault on the dropdown's mousedown suppresses the blur, which is the only thing onmousedown was buying — so the handler moves to `onclick`, the LAST event, where self-destruction is harmless and press-then-slide-off aborts like a button should. Because the input now keeps focus across a selection, `onfocus` no longer re-fires, so reopening is also wired to click/input — without that the picker could not be reopened without clicking away first. Also freezes the suggestion SOURCE while the dropdown is open. `allTags` is ordered by frequency and derives from `uploads`, which every `upload-processed` SSE replaces via refreshFeedInPlace — so an arriving photo could REORDER the open dropdown under the user's finger. At a party, where photos stream in continuously, that is a live mis-tap hazard. This was NOT the cause of the flake (I first believed it was; a clean 60-run said so and a 120-run refuted it), but it is a genuine defect on the same interaction, so it stays. Verified: 240/240 consecutive passes on the previously-flaky spec (baseline 2.8%, so ~0.1% odds of luck), full suite 162 passed / 1 skipped with zero failures. Co-Authored-By: Claude Opus 4.8 --- frontend/src/routes/feed/+page.svelte | 69 +++++++++++++++++++++++---- 1 file changed, 60 insertions(+), 9 deletions(-) diff --git a/frontend/src/routes/feed/+page.svelte b/frontend/src/routes/feed/+page.svelte index e1f7063..503b276 100644 --- a/frontend/src/routes/feed/+page.svelte +++ b/frontend/src/routes/feed/+page.svelte @@ -120,30 +120,59 @@ let allUploaders = $derived([...new Set(uploads.map((u) => u.uploader_name))].sort()); + // The suggestion SOURCE is frozen for as long as the dropdown is open. + // + // `allTags`/`allUploaders` derive from `uploads`, which mutates under us constantly: every + // `upload-processed` / `new-upload` SSE triggers `refreshFeedInPlace`, which replaces the array. + // `allTags` is ordered by FREQUENCY, so a photo landing mid-interaction can REORDER the open + // dropdown — the user presses "#wedding" and the list reshuffles under their finger. At a party, + // where photos stream in the whole time, that is a live mis-tap hazard, not a theoretical one. + // (It also detaches the button mid-press: the handler is `onmousedown`, so a re-render between + // mousedown and mouseup destroys the element being pressed.) + // + // Freezing on focus keeps the list the user is looking at identical to the list they act on. + // Typing still filters — it just filters a stable source. New photos appear in the suggestions + // the next time the dropdown is opened, which is soon enough for a filter picker. + let frozenTags = $state([]); + let frozenUploaders = $state([]); + + // Re-snapshot only when actually opening. Called on focus AND on click/input, because after a + // selection the input keeps focus (the dropdown suppresses the blur) — so `onfocus` will not + // fire again, and without these the picker could never be reopened without clicking away first. + // Guarding on `showAutocomplete` keeps typing from re-freezing on every keystroke, which would + // let the list churn again mid-interaction and undo the point of freezing it. + function openAutocomplete() { + if (!showAutocomplete) { + frozenTags = allTags; + frozenUploaders = allUploaders; + } + showAutocomplete = true; + } + let suggestions = $derived.by((): Filter[] => { const q = searchQuery.trim(); if (!q) { // Show top suggestions on focus if (!showAutocomplete) return []; return [ - ...allUploaders.slice(0, 3).map((u) => ({ type: 'user' as const, value: u })), - ...allTags.slice(0, 3).map((t) => ({ type: 'tag' as const, value: t })), + ...frozenUploaders.slice(0, 3).map((u) => ({ type: 'user' as const, value: u })), + ...frozenTags.slice(0, 3).map((t) => ({ type: 'tag' as const, value: t })), ]; } if (q.startsWith('#')) { const prefix = q.slice(1).toLowerCase(); - return allTags + return frozenTags .filter((t) => t.startsWith(prefix)) .slice(0, 8) .map((t) => ({ type: 'tag' as const, value: t })); } const lower = q.toLowerCase(); return [ - ...allUploaders + ...frozenUploaders .filter((u) => u.toLowerCase().includes(lower)) .slice(0, 4) .map((u) => ({ type: 'user' as const, value: u })), - ...allTags + ...frozenTags .filter((t) => t.includes(lower)) .slice(0, 4) .map((t) => ({ type: 'tag' as const, value: t })), @@ -568,10 +597,12 @@ placeholder="Nutzer oder #Tag suchen…" bind:value={searchQuery} onfocus={(e) => { - showAutocomplete = true; + openAutocomplete(); // Push the input above the virtual keyboard so suggestions stay visible. (e.currentTarget as HTMLInputElement).scrollIntoView({ block: 'center', behavior: 'smooth' }); }} + onclick={openAutocomplete} + oninput={openAutocomplete} onblur={() => setTimeout(() => (showAutocomplete = false), 150)} class="min-w-0 flex-1 bg-transparent text-sm text-gray-900 placeholder-gray-400 outline-none dark:text-gray-100 dark:placeholder-gray-500" /> @@ -590,11 +621,31 @@ {#if showAutocomplete && suggestions.length > 0} -
- {#each suggestions as item} + +
e.preventDefault()} + role="listbox" + tabindex="-1" + > + {#each suggestions as item (item.type + ':' + item.value)}