From 8f6b64addc6b448f0b99525f3f07c28b6fc36ed4 Mon Sep 17 00:00:00 2001 From: Codeon <313085171+codeon89@users.noreply.github.com> Date: Sun, 30 Aug 2026 14:18:00 -0700 Subject: [PATCH 1/2] enhancement: input debounce for library and browse --- CHANGELOG.md | 3 + src/App.jsx | 27 ++-- src/components/search/SearchBox.jsx | 14 +- src/components/search/SearchSidebar.jsx | 18 ++- src/hooks/useDebouncedSearch.js | 99 ++++++++++++ tests/search-debounce.test.jsx | 192 ++++++++++++++++++++++++ 6 files changed, 330 insertions(+), 23 deletions(-) create mode 100644 src/hooks/useDebouncedSearch.js create mode 100644 tests/search-debounce.test.jsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 85fa4818..3edded58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ - The version readout in the topnav and sidebar is now a button that opens that version's GitHub release page. The tag it builds matches what the release workflows publish -- `v` for stable and `v-nightly.` for nightly -- so it lands on the real release rather than a 404. (#143) - Added a "Download Version" entry to the split-button caret on the game detail page, beside "Manual Install". It opens the same downloads modal the UPDATE button does, listing every build and mirror the thread offers, so a different version can be fetched over one already installed. Previously an installed title with no pending update had no route to that modal at all: the primary button becomes PLAY once a version is installed, and the UPDATE button only renders when an update is flagged. The entry goes straight to the downloads modal rather than through the source picker, and is shown disabled with a reason for titles with no F95zone thread linked. +### Changed +- Update debounce logic for Browse and Library: Search in Catalog Browse and Library now debounces the text input and waits for a pause before filtering. Previously every keystroke updated `activeFilters.text` and ran `filterGamesWithState` (Library) or scheduled a catalog fetch, causing input lag on large libraries and a wasted local-filter pass even while browsing the server-side catalog. The input still echoes instantly from local state; clear bypasses the delay. + ### Fixed - Fixed `hasLocalPreviews` and `hasLocalBanners` missing already-downloaded assets, so a `missingOnly` media refresh re-downloaded images that were already on disk. Both now query `media_assets` and the `previews`/`banners` tables, which is what the delete paths already did. - Custom preview uploads now accept images only. The file picker filters to image types, the drop zone rejects anything else with a message, and the main process re-checks the extension before copying -- neither of the first two is a guarantee, and an unrecognised file copied into `data/images` became a preview tile that could never render. URL fetches are restricted to http(s), capped at 64 MB, throttle their progress events, and clean up the partial file if the download fails. diff --git a/src/App.jsx b/src/App.jsx index aba0343e..282f5ced 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -149,6 +149,7 @@ const App = () => { const [wishlistIdentityKeys, setWishlistIdentityKeys] = useState(new Set()) const [activeSavedFilterId, setActiveSavedFilterId] = useState('') const [savedFilterDeleteStateById, setSavedFilterDeleteStateById] = useState({}) + const [resetInputSignal, setResetInputSignal] = useState(0) // Banner card dimensions for Grid sizing — derived from the same // resolved template BannerTemplateProvider already computed once for // (see src/theme/BannerTemplateProvider.jsx), rather than @@ -1214,6 +1215,7 @@ const App = () => { setActiveSavedFilterId('') pendingLibraryScrollTopRestoreRef.current = 0 libraryScrollTopRef.current = 0 + setResetInputSignal((n) => n + 1) if (libraryMode === 'catalog') { // "Reset" in Browse mode should mean the whole catalog, not the // local library's installed-only default — otherwise resetting @@ -1889,7 +1891,11 @@ const App = () => { } }, [filterSidebarMode, selectedGame]) - const catalogResetDebounceRef = useRef(null) + // Search input already debounces via useDebouncedSearch (SearchBox/ + // SearchSidebar) before activeFilters updates, so debouncing again here + // would stack an extra delay before the spinner shows. Fetch immediately + // once the debounced filters arrive; the paramsKey guard still prevents + // the catalogTotal/enter-mode re-runs from wiping correct data. useEffect(() => { if (libraryMode !== 'catalog' || !browseAvailable) return const paramsKey = catalogParamsKey(catalogSearch, catalogQueryFilters) @@ -1902,18 +1908,8 @@ const App = () => { // the "banners flash, spinner, banners reload" sequence this fixes. return } - if (catalogResetDebounceRef.current) clearTimeout(catalogResetDebounceRef.current) - catalogResetDebounceRef.current = setTimeout(() => { - catalogResetDebounceRef.current = null - lastFetchedCatalogParamsKeyRef.current = paramsKey - fetchCatalogGames({ reset: true, search: catalogSearch, filters: catalogQueryFilters }) - }, 300) - return () => { - if (catalogResetDebounceRef.current) { - clearTimeout(catalogResetDebounceRef.current) - catalogResetDebounceRef.current = null - } - } + lastFetchedCatalogParamsKeyRef.current = paramsKey + fetchCatalogGames({ reset: true, search: catalogSearch, filters: catalogQueryFilters }) }, [browseAvailable, catalogQueryFilters, catalogSearch, catalogTotal, fetchCatalogGames, libraryMode]) // When the catalog index finishes building, anything already on screen in @@ -2106,7 +2102,7 @@ const App = () => { // layout there is no search box here and Collections is a nav // button instead (see TopNav's LEFT_ORDER).
- +
)} @@ -2244,6 +2240,7 @@ const App = () => { savedFilterDeleteStateById={savedFilterDeleteStateById} onApplySavedFilter={applySavedFilter} onDeleteSavedFilter={deleteSavedFilter} + resetInputSignal={resetInputSignal} onClose={() => setShowSearchSidebar(false)} /> @@ -2579,6 +2576,7 @@ const App = () => { savedFilterDeleteStateById={savedFilterDeleteStateById} onApplySavedFilter={applySavedFilter} onDeleteSavedFilter={deleteSavedFilter} + resetInputSignal={resetInputSignal} onClose={() => setShowSearchSidebar(false)} /> )} @@ -2601,6 +2599,7 @@ const App = () => { savedFilterDeleteStateById={savedFilterDeleteStateById} onApplySavedFilter={applySavedFilter} onDeleteSavedFilter={deleteSavedFilter} + resetInputSignal={resetInputSignal} onClose={() => setShowSearchSidebar(false)} /> )} diff --git a/src/components/search/SearchBox.jsx b/src/components/search/SearchBox.jsx index ad17d12f..a1c1f301 100644 --- a/src/components/search/SearchBox.jsx +++ b/src/components/search/SearchBox.jsx @@ -1,4 +1,8 @@ -export default function SearchBox({ value = "", onSearchChange, onToggleSidebar }) { +import { useDebouncedSearch } from '../../hooks/useDebouncedSearch.js' + +export default function SearchBox({ value = "", onSearchChange, onToggleSidebar, resetInputSignal }) { + const { localValue, handleChange, handleClear } = useDebouncedSearch({ value, onSearchChange, resetInputSignal }) + const handleInputKeyDown = (event) => { event.stopPropagation() } @@ -10,15 +14,15 @@ export default function SearchBox({ value = "", onSearchChange, onToggleSidebar onSearchChange?.(e.target.value)} + value={localValue} + onChange={(e) => handleChange(e.target.value)} onKeyDown={handleInputKeyDown} className="bg-transparent outline-none text-text flex-1 px-2 focus:outline-none -webkit-app-region-no-drag" /> - {value && ( + {localValue && (