fix(v2): keep the gallery search box in sync with the URL

The search box was only written by typing, and the refetch only ran from
the debounce, so a search term changed by navigation (the platform view
reuses the shell across /platform/:id) left a stale query in the box.
Move the debounced search into useDebouncedSearch, which mirrors the store
term into the box, and refetch from the shell's filter watch.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XZW9ChvmiTKXYYWY6JvTKU
This commit is contained in:
Claude
2026-10-01 04:12:17 +00:00
parent 2be99ae170
commit b57cbd3e51
3 changed files with 120 additions and 23 deletions
@@ -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<typeof setTimeout> | 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.
@@ -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<typeof effectScope>;
beforeEach(() => {
vi.useFakeTimers();
scope = effectScope();
});
afterEach(() => {
scope.stop();
vi.useRealTimers();
});
function setup(initial: string | null) {
const term = ref<string | null>(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 ");
});
});
@@ -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<string | null>, delayMs = 300) {
const input = ref(term.value ?? "");
let pending: ReturnType<typeof setTimeout> | 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 };
}