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());
|
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[] => {
|
let suggestions = $derived.by((): Filter[] => {
|
||||||
const q = searchQuery.trim();
|
const q = searchQuery.trim();
|
||||||
if (!q) {
|
if (!q) {
|
||||||
// Show top suggestions on focus
|
// Show top suggestions on focus
|
||||||
if (!showAutocomplete) return [];
|
if (!showAutocomplete) return [];
|
||||||
return [
|
return [
|
||||||
...allUploaders.slice(0, 3).map((u) => ({ type: 'user' as const, value: u })),
|
...frozenUploaders.slice(0, 3).map((u) => ({ type: 'user' as const, value: u })),
|
||||||
...allTags.slice(0, 3).map((t) => ({ type: 'tag' as const, value: t })),
|
...frozenTags.slice(0, 3).map((t) => ({ type: 'tag' as const, value: t })),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
if (q.startsWith('#')) {
|
if (q.startsWith('#')) {
|
||||||
const prefix = q.slice(1).toLowerCase();
|
const prefix = q.slice(1).toLowerCase();
|
||||||
return allTags
|
return frozenTags
|
||||||
.filter((t) => t.startsWith(prefix))
|
.filter((t) => t.startsWith(prefix))
|
||||||
.slice(0, 8)
|
.slice(0, 8)
|
||||||
.map((t) => ({ type: 'tag' as const, value: t }));
|
.map((t) => ({ type: 'tag' as const, value: t }));
|
||||||
}
|
}
|
||||||
const lower = q.toLowerCase();
|
const lower = q.toLowerCase();
|
||||||
return [
|
return [
|
||||||
...allUploaders
|
...frozenUploaders
|
||||||
.filter((u) => u.toLowerCase().includes(lower))
|
.filter((u) => u.toLowerCase().includes(lower))
|
||||||
.slice(0, 4)
|
.slice(0, 4)
|
||||||
.map((u) => ({ type: 'user' as const, value: u })),
|
.map((u) => ({ type: 'user' as const, value: u })),
|
||||||
...allTags
|
...frozenTags
|
||||||
.filter((t) => t.includes(lower))
|
.filter((t) => t.includes(lower))
|
||||||
.slice(0, 4)
|
.slice(0, 4)
|
||||||
.map((t) => ({ type: 'tag' as const, value: t })),
|
.map((t) => ({ type: 'tag' as const, value: t })),
|
||||||
@@ -568,10 +597,12 @@
|
|||||||
placeholder="Nutzer oder #Tag suchen…"
|
placeholder="Nutzer oder #Tag suchen…"
|
||||||
bind:value={searchQuery}
|
bind:value={searchQuery}
|
||||||
onfocus={(e) => {
|
onfocus={(e) => {
|
||||||
showAutocomplete = true;
|
openAutocomplete();
|
||||||
// Push the input above the virtual keyboard so suggestions stay visible.
|
// Push the input above the virtual keyboard so suggestions stay visible.
|
||||||
(e.currentTarget as HTMLInputElement).scrollIntoView({ block: 'center', behavior: 'smooth' });
|
(e.currentTarget as HTMLInputElement).scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||||
}}
|
}}
|
||||||
|
onclick={openAutocomplete}
|
||||||
|
oninput={openAutocomplete}
|
||||||
onblur={() => setTimeout(() => (showAutocomplete = false), 150)}
|
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"
|
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 -->
|
<!-- Autocomplete dropdown -->
|
||||||
{#if showAutocomplete && suggestions.length > 0}
|
{#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">
|
<!-- `preventDefault` on the container's mousedown stops the input from blurring, which
|
||||||
{#each suggestions as item}
|
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
|
<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"
|
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'}
|
{#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">
|
<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