mirror of
https://github.com/rommapp/romm.git
synced 2026-10-12 02:21:58 +00:00
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:
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user