From b11422d62e16aa038961089e0a7fa433b7fb2abc Mon Sep 17 00:00:00 2001 From: wbottle Date: Thu, 8 Oct 2026 18:34:06 +0400 Subject: [PATCH] fix(browse): preserve filters across section switches --- scripts/ui-baseline.json | 2 +- src/lib/browseFilters.test.ts | 25 ++++++++++ src/lib/browseFilters.ts | 18 +++++++ src/pages/Browse.tsx | 37 +++++++-------- src/stores/appStore.browseUi.test.ts | 71 ++++++++++++++++++++++++++++ src/stores/appStore.ts | 26 +++++++++- 6 files changed, 158 insertions(+), 21 deletions(-) create mode 100644 src/lib/browseFilters.test.ts create mode 100644 src/lib/browseFilters.ts diff --git a/scripts/ui-baseline.json b/scripts/ui-baseline.json index bc328475..4ebfd7e6 100644 --- a/scripts/ui-baseline.json +++ b/scripts/ui-baseline.json @@ -199,7 +199,7 @@ "src/pages/Browse.tsx": { "raw-palette": 1, "focus-ring": 2, - "em-dash": 10 + "em-dash": 9 }, "src/pages/Conflicts.tsx": { "raw-palette": 23, diff --git a/src/lib/browseFilters.test.ts b/src/lib/browseFilters.test.ts new file mode 100644 index 00000000..4a452475 --- /dev/null +++ b/src/lib/browseFilters.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import { browseRemoteFilters } from './browseFilters'; + +describe('Browse remote hero filters', () => { + it('uses the hero category for Mods', () => { + expect(browseRemoteFilters({ + section: 'Mod', search: 'blue', heroCategoryId: 42, + selectedHeroName: 'Haze', effectiveCategoryId: 42, + })).toEqual({ search: 'blue', categoryId: 42 }); + }); + + it.each(['Sound', 'Wip'])('searches %s by hero name without a ModCategory id', (section) => { + expect(browseRemoteFilters({ + section, search: ' blue ', heroCategoryId: 42, + selectedHeroName: 'Haze', effectiveCategoryId: 42, + })).toEqual({ search: 'blue Haze', categoryId: undefined }); + }); + + it('keeps a Sound category with the No hero filter', () => { + expect(browseRemoteFilters({ + section: 'Sound', search: '', heroCategoryId: 'none', + selectedHeroName: undefined, effectiveCategoryId: 7, + })).toEqual({ search: undefined, categoryId: 7 }); + }); +}); diff --git a/src/lib/browseFilters.ts b/src/lib/browseFilters.ts new file mode 100644 index 00000000..c7ddfe65 --- /dev/null +++ b/src/lib/browseFilters.ts @@ -0,0 +1,18 @@ +import type { BrowseUiState } from '../stores/appStore'; + +export function browseRemoteFilters({ + section, search, heroCategoryId, selectedHeroName, effectiveCategoryId, +}: Pick & { + selectedHeroName: string | undefined; + effectiveCategoryId: number | undefined; +}): { search: string | undefined; categoryId: number | undefined } { + // Hero ids come from ModCategory. Sounds and WiPs must search by name + // instead of sending that id to their unrelated category trees. + if (section !== 'Mod' && typeof heroCategoryId === 'number') { + return { + search: [search.trim(), selectedHeroName].filter(Boolean).join(' ') || undefined, + categoryId: undefined, + }; + } + return { search: search || undefined, categoryId: effectiveCategoryId }; +} diff --git a/src/pages/Browse.tsx b/src/pages/Browse.tsx index b723cb02..5d0329c3 100644 --- a/src/pages/Browse.tsx +++ b/src/pages/Browse.tsx @@ -38,6 +38,7 @@ import { } from '../lib/api'; import { getActiveDeadlockPath, shouldBlurNsfw } from '../lib/appSettings'; import { useStableCallback } from '../lib/useStableCallback'; +import { browseRemoteFilters } from '../lib/browseFilters'; import { isModDownloadPending, getVisibleDownloadQueue, @@ -498,6 +499,7 @@ export default function Browse() { const perPage = DEFAULT_PER_PAGE; // Fixed value for infinite scroll const [sections, setSections] = useState([]); const [categories, setCategories] = useState([]); + const [categoriesSection, setCategoriesSection] = useState(null); // Hero list comes from the Mod section's category tree (Mod -> Skins -> heroes). // Cached separately so hero filtering still works on the Sound tab, where the // current section's category tree has no Skins parent. @@ -798,6 +800,7 @@ export default function Browse() { // having no items, and stale ids restored from a previous session. const rootCategoryNameById = useMemo(() => { const map = new Map(); + if (categoriesSection !== section) return map; const walk = (nodes: GameBananaCategoryNode[], rootName: string | null) => { for (const node of nodes) { const root = rootName ?? node.name; @@ -807,7 +810,7 @@ export default function Browse() { }; walk(categories, null); return map; - }, [categories]); + }, [categories, categoriesSection, section]); const effectiveCategoryName = effectiveCategoryId === undefined ? undefined : rootCategoryNameById.get(effectiveCategoryId); @@ -1134,15 +1137,18 @@ export default function Browse() { setError(null); try { + const remoteFilters = browseRemoteFilters({ + section, search: effectiveSearch, heroCategoryId, selectedHeroName, effectiveCategoryId, + }); const response = await browseMods( page, perPage, // In artist mode the grid is scoped by submitter; text search and // category don't combine cleanly with that on the API, so they're // dropped while viewing an artist. - submitter ? undefined : (effectiveSearch || undefined), + submitter ? undefined : remoteFilters.search, section, - submitter ? undefined : effectiveCategoryId, + submitter ? undefined : remoteFilters.categoryId, sort !== 'default' ? sort : undefined, submitter?.id ); @@ -1228,6 +1234,8 @@ export default function Browse() { section, perPage, effectiveCategoryId, + heroCategoryId, + selectedHeroName, fetchFilterStamp, useLocalSearch, submitter, @@ -1473,35 +1481,25 @@ export default function Browse() { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - // Track whether `section` actually changed (vs. the effect just firing on - // mount because deps populated for the first time). Without this guard the - // hero/category filters were reset to 'all' on every remount, which then - // triggered a refetch and threw away the cached scroll position. - const lastLoadedSectionRef = useRef(null); + // The store switches filters with the section. Category loading must never + // reset them, including a selection made while this request is in flight. useEffect(() => { let active = true; const selected = sections.find((entry) => entry.modelName === section); if (!selected) { setCategories([]); + setCategoriesSection(null); return () => { active = false; }; } - const sectionChanged = lastLoadedSectionRef.current !== null && lastLoadedSectionRef.current !== section; - lastLoadedSectionRef.current = section; - const loadCategories = async () => { try { const data = await getGamebananaCategories(selected.categoryModelName); if (!active) return; setCategories(data); - // Only reset the hero/category filter when the user actually - // switched sections — not on every remount. - if (sectionChanged) { - setHeroCategoryId('all'); - setCategoryId('all'); - } + setCategoriesSection(section); } catch (err) { if (active) { setError(String(err)); @@ -1514,7 +1512,7 @@ export default function Browse() { return () => { active = false; }; - }, [sections, section, setCategoryId, setHeroCategoryId]); + }, [sections, section]); // fetchMods and searchLocal share `lastFetchedStampRef`, and its key // (`page` + fetchFilterStamp) describes the FILTERS only, not which backend @@ -2010,6 +2008,7 @@ export default function Browse() { }, [modCategories]); const categoryOptions = useMemo(() => { + if (categoriesSection !== section) return []; // Per-hero entries under Skins are handled by the dedicated Hero filter, so // exclude them here. Everything else (Skins, Model Replacement, HUD, // Gameplay Modifications, Maps, Music, Killsounds, ...) becomes a mod-type @@ -2032,7 +2031,7 @@ export default function Browse() { } return Array.from(byLabel.values()).sort((a, b) => a.label.localeCompare(b.label)); - }, [categories, heroOptions]); + }, [categories, categoriesSection, section, heroOptions]); const installedIds = useMemo(() => { const ids = new Set(); diff --git a/src/stores/appStore.browseUi.test.ts b/src/stores/appStore.browseUi.test.ts index bf087033..437230cd 100644 --- a/src/stores/appStore.browseUi.test.ts +++ b/src/stores/appStore.browseUi.test.ts @@ -8,6 +8,77 @@ vi.mock('../i18n', () => ({ const sqooky: BrowseArtistRef = { id: 3826762, name: 'Sqooky!' }; +describe('appStore Browse section filters', () => { + beforeEach(() => useAppStore.getState().resetBrowseUi()); + + it('keeps the selected hero and shared filters through all sections', () => { + const { setBrowseUi } = useAppStore.getState(); + setBrowseUi({ heroCategoryId: 42, search: 'blue', sort: 'popular', addedWithin: 'custom', addedFrom: '2026-10-01', addedTo: '2026-10-08' }); + for (const section of ['Sound', 'Wip', 'Mod']) { + setBrowseUi({ section }); + expect(useAppStore.getState().browseUi).toMatchObject({ + section, heroCategoryId: 42, search: 'blue', sort: 'popular', + addedWithin: 'custom', addedFrom: '2026-10-01', addedTo: '2026-10-08', + }); + } + }); + + it('restores each section category without carrying ids to another section', () => { + const { setBrowseUi } = useAppStore.getState(); + setBrowseUi({ categoryId: 100 }); + setBrowseUi({ section: 'Sound' }); + expect(useAppStore.getState().browseUi.categoryId).toBe('all'); + setBrowseUi({ categoryId: 200 }); + setBrowseUi({ section: 'Wip', categoryId: 300 }); + for (const { section, categoryId } of [ + { section: 'Mod', categoryId: 100 }, + { section: 'Sound', categoryId: 200 }, + { section: 'Wip', categoryId: 300 }, + ]) { + setBrowseUi({ section }); + expect(useAppStore.getState().browseUi.categoryId).toBe(categoryId); + } + }); + + it('keeps No hero specific to Sounds and restores it when returning', () => { + const { setBrowseUi } = useAppStore.getState(); + setBrowseUi({ section: 'Sound', heroCategoryId: 'none' }); + for (const section of ['Mod', 'Wip']) { + setBrowseUi({ section }); + expect(useAppStore.getState().browseUi.heroCategoryId).toBe('all'); + } + setBrowseUi({ section: 'Sound' }); + expect(useAppStore.getState().browseUi.heroCategoryId).toBe('none'); + setBrowseUi({ section: 'Mod', heroCategoryId: 42 }); + setBrowseUi({ section: 'Sound' }); + expect(useAppStore.getState().browseUi.heroCategoryId).toBe(42); + }); + + it('honors explicit navigation filters and clearing a restored category', () => { + const { setBrowseUi } = useAppStore.getState(); + setBrowseUi({ categoryId: 100 }); + setBrowseUi({ section: 'Sound', categoryId: 200 }); + setBrowseUi({ section: 'Mod', heroCategoryId: 42, categoryId: 'all' }); + expect(useAppStore.getState().browseUi).toMatchObject({ heroCategoryId: 42, categoryId: 'all' }); + setBrowseUi({ section: 'Sound' }); + setBrowseUi({ categoryId: 'all', heroCategoryId: 'all' }); + setBrowseUi({ section: 'Mod' }); + setBrowseUi({ section: 'Sound' }); + expect(useAppStore.getState().browseUi).toMatchObject({ heroCategoryId: 'all', categoryId: 'all' }); + }); + + it('resets section memories with the Browse session', () => { + const { setBrowseUi, resetBrowseUi } = useAppStore.getState(); + setBrowseUi({ categoryId: 100 }); + setBrowseUi({ section: 'Sound', heroCategoryId: 'none' }); + resetBrowseUi(); + setBrowseUi({ section: 'Sound' }); + expect(useAppStore.getState().browseUi).toMatchObject({ heroCategoryId: 'all', categoryId: 'all' }); + setBrowseUi({ section: 'Mod' }); + expect(useAppStore.getState().browseUi.categoryId).toBe('all'); + }); +}); + describe('appStore Browse artist overrides', () => { beforeEach(() => { useAppStore.getState().resetBrowseUi(); diff --git a/src/stores/appStore.ts b/src/stores/appStore.ts index 5c1302eb..e07360c2 100644 --- a/src/stores/appStore.ts +++ b/src/stores/appStore.ts @@ -207,6 +207,9 @@ export interface BrowseUiState { // For Mod section it collapses to 'all' since every Skin lives under a hero. heroCategoryId: number | 'all' | 'none'; categoryId: number | 'all'; + // Category ids belong to their section. Remember them for this session only. + categoryIdsBySection: Partial>; + soundNoHero: boolean; // Artist mode: when set, the grid lists only this submitter's mods // (GameBanana Generic_Submitter filter) and Browse shows an artist banner. // Session-only; carries display fields so the banner needs no extra fetch. @@ -284,6 +287,8 @@ const DEFAULT_BROWSE_UI: BrowseUiState = { addedTo: '', heroCategoryId: 'all', categoryId: 'all', + categoryIdsBySection: {}, + soundNoHero: false, }; // Cached page state: lets the Browse tab resume exactly where the user left @@ -1281,7 +1286,26 @@ export const useAppStore = create((set, get) => ({ // a time without restating the rest. layout + sort also mirror to // localStorage so they persist across app restarts. setBrowseUi: (partial: Partial) => { - const next = { ...get().browseUi, ...partial }; + const current = get().browseUi; + const next = { ...current, ...partial }; + if (next.section !== current.section) { + next.categoryIdsBySection = { + ...current.categoryIdsBySection, + [current.section]: current.categoryId, + }; + next.categoryId = partial.categoryId ?? next.categoryIdsBySection[next.section] ?? 'all'; + next.heroCategoryId = partial.heroCategoryId ?? ( + next.section === 'Sound' && current.soundNoHero + ? 'none' + : current.heroCategoryId === 'none' ? 'all' : current.heroCategoryId + ); + } + if (partial.heroCategoryId !== undefined) { + next.soundNoHero = next.section === 'Sound' && partial.heroCategoryId === 'none'; + } + if (next.section !== 'Sound' && next.heroCategoryId === 'none') { + next.heroCategoryId = 'all'; + } // An override belongs to the exact navigation that supplied it. Any // ordinary submitter change must drop it so a later Installed/Browse link // cannot accidentally reveal a creator the user chose to hide.