diff --git a/frontend/src/v2/components/Gallery/GalleryShell.vue b/frontend/src/v2/components/Gallery/GalleryShell.vue index 2a76e0cc1..8be49dbbf 100644 --- a/frontend/src/v2/components/Gallery/GalleryShell.vue +++ b/frontend/src/v2/components/Gallery/GalleryShell.vue @@ -60,6 +60,7 @@ import { import { GameCard, GameCardSkeleton } from "@/v2/components/GameCard"; import { useBreakpoint } from "@/v2/composables/useBreakpoint"; import { coverRatio, isBoxartStyle } from "@/v2/composables/useCoverArt"; +import { useDebouncedSearch } from "@/v2/composables/useDebouncedSearch"; import { useDebugMode } from "@/v2/composables/useDebugMode"; import { useGalleryCoverRatios } from "@/v2/composables/useGalleryCoverRatios"; import { useGalleryFilterUrl } from "@/v2/composables/useGalleryFilterUrl"; @@ -237,13 +238,13 @@ const filterActiveCount = computed(() => { return n; }); -// Filter changes → refetch the gallery. Mirrors the search debounced -// path (invalidate windows + bootstrap initial metadata). The watch -// fires only on subsequent changes; the initial hydration done by -// `useGalleryFilterUrl` happens before this watch is set up and so -// does not echo here. +// Search and filter changes → refetch the gallery: invalidate windows and +// bootstrap metadata only, since rows hydrate per-position as they mount. +// The initial hydration done by `useGalleryFilterUrl` happens before this +// watch is set up and so does not echo here. watch( [ + searchTerm, filterMatched, filterFavorites, filterDuplicates, @@ -747,23 +748,7 @@ watch( ); // ── Search filter (debounced) ─────────────────────────────────────── -const searchInput = ref(searchTerm.value ?? ""); -let searchDebounce: ReturnType | null = null; -function setSearch(value: string) { - searchInput.value = value; - if (searchDebounce) clearTimeout(searchDebounce); - searchDebounce = setTimeout(() => { - const normalized = value.trim(); - if (normalized === (searchTerm.value ?? "")) return; - searchTerm.value = normalized || null; - // Both layouts share the same loading model: invalidate and - // bootstrap metadata only; rows hydrate per-position via the row - // component's mount lifecycle (grid: GameCard via shell-level - // viewport-sync; list: GameListRow via its own onMounted). - galleryRoms.invalidateWindows(); - void galleryRoms.fetchInitialMetadata(); - }, 300); -} +const { input: searchInput, setSearch } = useDebouncedSearch(searchTerm); // ── Sort ────────────────────────────────────────────────────────── // Both affordances (list column headers, grid direction toggle) only @@ -873,7 +858,6 @@ onBeforeUnmount(() => { // navigation back to a non-gallery view (Home, Settings) doesn't // keep stale picks alive. gallerySelection.clear(); - if (searchDebounce) clearTimeout(searchDebounce); if (fetchDebounceTimer) clearTimeout(fetchDebounceTimer); // When leaving the gallery entirely, stop any in-flight window fetches so // navigating away mid-scroll doesn't keep the network / backend busy. diff --git a/frontend/src/v2/composables/useDebouncedSearch/index.test.ts b/frontend/src/v2/composables/useDebouncedSearch/index.test.ts new file mode 100644 index 000000000..09c8eda7a --- /dev/null +++ b/frontend/src/v2/composables/useDebouncedSearch/index.test.ts @@ -0,0 +1,78 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { effectScope, nextTick, ref } from "vue"; +import { useDebouncedSearch } from "./index"; + +describe("useDebouncedSearch", () => { + let scope: ReturnType; + + beforeEach(() => { + vi.useFakeTimers(); + scope = effectScope(); + }); + + afterEach(() => { + scope.stop(); + vi.useRealTimers(); + }); + + function setup(initial: string | null) { + const term = ref(initial); + const search = scope.run(() => useDebouncedSearch(term))!; + return { term, ...search }; + } + + it("writes the trimmed term once typing settles", () => { + const { term, input, setSearch } = setup(null); + + setSearch("mar"); + setSearch(" mario "); + expect(input.value).toBe(" mario "); + expect(term.value).toBeNull(); + + vi.advanceTimersByTime(300); + expect(term.value).toBe("mario"); + }); + + it("clears the term when the box is emptied", () => { + const { term, setSearch } = setup("mario"); + + setSearch(" "); + vi.advanceTimersByTime(300); + expect(term.value).toBeNull(); + }); + + it("shows a term set by navigation in the box", async () => { + const { term, input } = setup("mario"); + + term.value = "zelda"; + await nextTick(); + expect(input.value).toBe("zelda"); + + term.value = null; + await nextTick(); + expect(input.value).toBe(""); + }); + + it("drops a pending keystroke when navigation sets the term first", async () => { + const { term, input, setSearch } = setup(null); + + setSearch("mario"); + term.value = "zelda"; + await nextTick(); + vi.advanceTimersByTime(300); + + expect(term.value).toBe("zelda"); + expect(input.value).toBe("zelda"); + }); + + it("keeps what the user typed when the term it settled on echoes back", async () => { + const { term, input, setSearch } = setup(null); + + setSearch("mario "); + vi.advanceTimersByTime(300); + await nextTick(); + + expect(term.value).toBe("mario"); + expect(input.value).toBe("mario "); + }); +}); diff --git a/frontend/src/v2/composables/useDebouncedSearch/index.ts b/frontend/src/v2/composables/useDebouncedSearch/index.ts new file mode 100644 index 000000000..6b7833981 --- /dev/null +++ b/frontend/src/v2/composables/useDebouncedSearch/index.ts @@ -0,0 +1,35 @@ +import { onScopeDispose, type Ref, ref, watch } from "vue"; + +/** A search box bound to a store term: typing writes the trimmed term after + * `delayMs`, and a term set elsewhere (back/forward, a pasted link) shows up + * in the box straight away. */ +export function useDebouncedSearch(term: Ref, delayMs = 300) { + const input = ref(term.value ?? ""); + let pending: ReturnType | null = null; + + function cancel() { + if (pending) clearTimeout(pending); + pending = null; + } + + function setSearch(value: string) { + input.value = value; + cancel(); + pending = setTimeout(() => { + pending = null; + const normalized = value.trim(); + if (normalized !== (term.value ?? "")) term.value = normalized || null; + }, delayMs); + } + + watch(term, (value) => { + if (input.value.trim() === (value ?? "")) return; + // A keystroke still waiting would overwrite the term just navigated to. + cancel(); + input.value = value ?? ""; + }); + + onScopeDispose(cancel); + + return { input, setSearch }; +}