fix(feed): commit filter suggestions on click, not mousedown
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<string[]>([]);
|
||||
let frozenUploaders = $state<string[]>([]);
|
||||
|
||||
// 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 @@
|
||||
|
||||
<!-- Autocomplete dropdown -->
|
||||
{#if showAutocomplete && suggestions.length > 0}
|
||||
<div class="absolute left-0 right-0 top-full z-50 mt-1 overflow-hidden rounded-xl border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-900">
|
||||
{#each suggestions as item}
|
||||
<!-- `preventDefault` on the container's mousedown stops the input from blurring, which
|
||||
is what would otherwise close this dropdown (see the input's `onblur`) before a
|
||||
click could land on a suggestion.
|
||||
|
||||
That matters because the suggestions used to commit on `onmousedown` — purely to
|
||||
beat that blur. Two things were wrong with it. It fired on PRESS, so sliding off a
|
||||
suggestion you didn't mean to hit still applied the filter, with no way to abort.
|
||||
And `selectSuggestion` sets `showAutocomplete = false`, so the button DESTROYED
|
||||
ITSELF on the first event of the click sequence: whether the node survived to
|
||||
`mouseup` came down to whether Svelte's flush landed in between. That is the whole
|
||||
reason this spec was flaky under load.
|
||||
|
||||
Suppressing the blur lets the handler move to `onclick` — the LAST event — so the
|
||||
element self-destructing is harmless, and press-then-slide-off aborts like a button
|
||||
should. -->
|
||||
<div
|
||||
class="absolute left-0 right-0 top-full z-50 mt-1 overflow-hidden rounded-xl border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-900"
|
||||
onmousedown={(e) => e.preventDefault()}
|
||||
role="listbox"
|
||||
tabindex="-1"
|
||||
>
|
||||
{#each suggestions as item (item.type + ':' + item.value)}
|
||||
<button
|
||||
class="flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||
onmousedown={() => selectSuggestion(item)}
|
||||
onclick={() => selectSuggestion(item)}
|
||||
>
|
||||
{#if item.type === 'user'}
|
||||
<svg class="h-4 w-4 shrink-0 text-gray-400 dark:text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
|
||||
Reference in New Issue
Block a user