From e85f2fe4ef291af05c96368c4970f5d4b2e9f7b1 Mon Sep 17 00:00:00 2001 From: Spinnich Date: Sun, 2 Aug 2026 15:49:37 +0000 Subject: [PATCH 1/2] fix(home): stop scanning the library filesystem on every home page load The v2 home page requested GET /api/setup/library on every visit, which walks every platform directory under the library path to count files. That hint only exists for brand-new instances, but it fired for everyone: 17s per home page load on an 83k-game library, 38s on slower storage, and the result was thrown away because the library wasn't empty. The frontend watcher was wired to `isEmpty` with `immediate: true`, so it ran during setup, before any of the page's data requests had started. All the `fetching*` flags were still false and the stores were still empty, so the library looked empty and the walk kicked off. Frontend: onMounted now awaits the initial fetches and flips an `initialLoadDone` flag. The watcher and the empty-state `v-if` both gate on `initialLoadDone && isEmpty`, so the request and the render can no longer disagree about whether the library is empty. Backend: get_setup_library_info() returns early with an empty `existing_platforms` once the database holds ROMs, since the on-disk hint is meaningless then. Gated on ROMs alone, not platforms: platform rows with zero ROMs are exactly the case the hint exists for, so those still walk. Fixes #4063 Co-Authored-By: Claude Opus 5 --- backend/endpoints/heartbeat.py | 13 +- backend/handler/database/roms_handler.py | 13 ++ backend/tests/endpoints/test_heartbeat.py | 66 +++++++ frontend/src/v2/views/Home.test.ts | 223 ++++++++++++++++++++++ frontend/src/v2/views/Home.vue | 50 +++-- 5 files changed, 347 insertions(+), 18 deletions(-) create mode 100644 frontend/src/v2/views/Home.test.ts diff --git a/backend/endpoints/heartbeat.py b/backend/endpoints/heartbeat.py index 7f1cae5d08..76da893198 100644 --- a/backend/endpoints/heartbeat.py +++ b/backend/endpoints/heartbeat.py @@ -29,7 +29,7 @@ from endpoints.responses.heartbeat import HeartbeatResponse from exceptions.fs_exceptions import PlatformAlreadyExistsException from handler.auth.constants import Scope -from handler.database import db_user_handler +from handler.database import db_rom_handler, db_user_handler from handler.filesystem import fs_platform_handler from handler.filesystem.base_handler import LibraryStructure from handler.metadata import ( @@ -214,6 +214,17 @@ async def get_setup_library_info(request: Request): detected_structure = fs_platform_handler.detect_library_structure() + # The per-platform rom counts below are a first-run hint, so a fresh + # instance can show what RomM already sees on disk. Once the database + # holds ROMs that hint is dead weight, and building it walks every + # platform directory: tens of seconds on a large library. + if db_rom_handler.has_any_rom(): + return { + "detected_structure": detected_structure, + "existing_platforms": [], + "supported_platforms": get_supported_platforms(), + } + # Get existing platforms from filesystem try: existing_platform_slugs = await fs_platform_handler.get_platforms() diff --git a/backend/handler/database/roms_handler.py b/backend/handler/database/roms_handler.py index 353cea1768..0c1495a5ba 100644 --- a/backend/handler/database/roms_handler.py +++ b/backend/handler/database/roms_handler.py @@ -1642,6 +1642,19 @@ def get_rom_count( or 0 ) + @begin_session + def has_any_rom( + self, + *, + session: Session = None, # type: ignore + ) -> bool: + """Whether the library holds at least one ROM. + + An existence probe rather than a count, for callers that only + need to know whether the library has ever been scanned. + """ + return session.scalar(select(Rom.id).limit(1)) is not None + @begin_session def get_roms_by_fs_name( self, diff --git a/backend/tests/endpoints/test_heartbeat.py b/backend/tests/endpoints/test_heartbeat.py index 054290d64e..00a20e9ed5 100644 --- a/backend/tests/endpoints/test_heartbeat.py +++ b/backend/tests/endpoints/test_heartbeat.py @@ -239,6 +239,72 @@ def test_get_setup_library_info_handles_errors(client, admin_user, access_token) assert data["existing_platforms"] == [] +def test_get_setup_library_info_skips_filesystem_walk_when_roms_exist( + client, rom, access_token +): + """A library with scanned ROMs never needs the on-disk hint, so skip the walk.""" + with ( + patch( + "endpoints.heartbeat.fs_platform_handler.detect_library_structure" + ) as mock_detect, + patch( + "endpoints.heartbeat.fs_platform_handler.get_platforms" + ) as mock_get_platforms, + ): + mock_detect.return_value = "struct_a" + mock_get_platforms.return_value = ["n64"] + + response = client.get( + "/api/setup/library", + headers={"Authorization": f"Bearer {access_token}"}, + ) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + + assert data["detected_structure"] == "struct_a" + assert data["existing_platforms"] == [] + assert len(data["supported_platforms"]) > 0 + mock_get_platforms.assert_not_called() + + +def test_get_setup_library_info_walks_when_platforms_have_no_roms( + client, platform, access_token +): + """Platform rows without ROMs still need the hint: that is the case it exists for.""" + with ( + patch( + "endpoints.heartbeat.fs_platform_handler.detect_library_structure" + ) as mock_detect, + patch( + "endpoints.heartbeat.fs_platform_handler.get_platforms" + ) as mock_get_platforms, + patch("endpoints.heartbeat.AnyioPath") as mock_anyio_path, + ): + mock_detect.return_value = "struct_a" + mock_get_platforms.return_value = ["n64"] + + async def mock_iterdir(): + entry = MagicMock() + entry.name = "game1.z64" + yield entry + + mock_path = AsyncMock() + mock_path.exists = AsyncMock(return_value=True) + mock_path.iterdir = mock_iterdir + mock_anyio_path.return_value = mock_path + + response = client.get( + "/api/setup/library", + headers={"Authorization": f"Bearer {access_token}"}, + ) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + + assert data["existing_platforms"] == [{"fs_slug": "n64", "rom_count": 1}] + + def test_create_setup_platforms_success(client, admin_user, access_token): """Test create_setup_platforms successfully creates platforms""" platform_slugs = ["n64", "psx", "gba"] diff --git a/frontend/src/v2/views/Home.test.ts b/frontend/src/v2/views/Home.test.ts new file mode 100644 index 0000000000..3f58ee8797 --- /dev/null +++ b/frontend/src/v2/views/Home.test.ts @@ -0,0 +1,223 @@ +/* eslint-disable vue/one-component-per-file */ +import { flushPromises, mount } from "@vue/test-utils"; +import { createPinia, setActivePinia } from "pinia"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { defineComponent, ref } from "vue"; +import storeCollections, { type Collection } from "@/stores/collections"; +import storePlatforms, { type Platform } from "@/stores/platforms"; +import storeRoms, { type SimpleRom } from "@/stores/roms"; +import Home from "./Home.vue"; + +vi.mock("vue-i18n", () => ({ + useI18n: () => ({ t: (key: string) => key }), +})); + +const { getLibraryInfo } = vi.hoisted(() => ({ + getLibraryInfo: vi.fn(), +})); + +vi.mock("@/services/api/setup", () => ({ + default: { getLibraryInfo }, +})); + +vi.mock("@v2/lib", () => ({ + RChip: defineComponent({ template: "" }), + RDivider: defineComponent({ template: "
" }), + RIcon: defineComponent({ template: "" }), + RSkeletonBlock: defineComponent({ template: "
" }), +})); + +vi.mock("@/v2/components/Collections/CollectionTile.vue", () => ({ + default: defineComponent({ template: "
" }), +})); + +vi.mock("@/v2/components/GameCard", () => ({ + GameCard: defineComponent({ template: "
" }), + GameCardSkeleton: defineComponent({ template: "
" }), +})); + +vi.mock("@/v2/components/Home/CardRow.vue", () => ({ + default: defineComponent({ template: "
" }), +})); + +vi.mock("@/v2/components/Home/Widgets/WidgetBar.vue", () => ({ + default: defineComponent({ template: "
" }), +})); + +vi.mock("@/v2/components/Platforms/PlatformTile.vue", () => ({ + default: defineComponent({ template: "
" }), +})); + +vi.mock("@/v2/composables/useGridNav", () => ({ + useGridNav: vi.fn(), +})); + +vi.mock("@/v2/composables/useWebpSupport", () => ({ + useWebpSupport: () => ({ + supportsWebp: ref(false), + toWebp: (url: string) => url, + }), +})); + +vi.mock("@/composables/useUISettings", () => ({ + useUISettings: () => ({ + showHomeWidgets: ref(true), + showRecentRoms: ref(true), + showContinuePlaying: ref(true), + showPlatforms: ref(true), + showCollections: ref(true), + showSmartCollections: ref(false), + showVirtualCollections: ref(false), + virtualCollectionType: ref("collection"), + }), +})); + +function platform(id: number): Platform { + return { + id, + display_name: `Platform ${id}`, + name: `Platform ${id}`, + slug: `platform-${id}`, + fs_slug: `platform-${id}`, + rom_count: 12, + } as Platform; +} + +function collection(id: number): Collection { + return { + id, + name: `Collection ${id}`, + rom_count: 3, + rom_ids: [], + } as unknown as Collection; +} + +function rom(id: number): SimpleRom { + return { id, name: `Rom ${id}` } as SimpleRom; +} + +/** + * Resolve on a later microtask, the way a real request does. Anything + * asserting on the home page's initial-load ordering has to see the + * stores fill in after setup, not during it. + */ +async function afterRoundTrip(populate: () => T): Promise { + await Promise.resolve(); + return populate(); +} + +/** Wire up every home page fetch; `populated` decides what comes back. */ +function stubHomeFetches(populated: boolean) { + const platforms = storePlatforms(); + const collections = storeCollections(); + const roms = storeRoms(); + + vi.spyOn(platforms, "fetchPlatforms").mockImplementation(() => { + platforms.fetchingPlatforms = true; + return afterRoundTrip(() => { + const loaded = populated ? [platform(1)] : []; + platforms.set(loaded); + platforms.fetchingPlatforms = false; + return loaded; + }); + }); + + vi.spyOn(collections, "fetchCollections").mockImplementation(() => { + collections.fetchingCollections = true; + return afterRoundTrip(() => { + const loaded = populated ? [collection(1)] : []; + collections.setCollections(loaded); + collections.fetchingCollections = false; + return loaded; + }); + }); + + vi.spyOn(collections, "fetchSmartCollections").mockImplementation(() => { + collections.fetchingSmartCollections = true; + return afterRoundTrip(() => { + collections.fetchingSmartCollections = false; + return []; + }); + }); + + vi.spyOn(collections, "fetchVirtualCollections").mockImplementation(() => { + collections.fetchingVirtualCollections = true; + return afterRoundTrip(() => { + collections.fetchingVirtualCollections = false; + return []; + }); + }); + + vi.spyOn(roms, "fetchRecentRoms").mockImplementation(() => + afterRoundTrip(() => { + const loaded = populated ? [rom(1)] : []; + roms.setRecentRoms(loaded); + return loaded; + }), + ); + + vi.spyOn(roms, "fetchContinuePlayingRoms").mockImplementation(() => + afterRoundTrip(() => { + const loaded = populated ? [rom(2)] : []; + roms.setContinuePlayingRoms(loaded); + return loaded; + }), + ); + + return { platforms, collections, roms }; +} + +function mountHome() { + return mount(Home, { + global: { + stubs: { RouterLink: defineComponent({ template: "" }) }, + }, + }); +} + +describe("Home", () => { + beforeEach(() => { + setActivePinia(createPinia()); + getLibraryInfo.mockReset(); + getLibraryInfo.mockResolvedValue({ + data: { detected_structure: "struct_a", existing_platforms: [] }, + }); + }); + + it("never walks the filesystem for a populated library", async () => { + stubHomeFetches(true); + + const wrapper = mountHome(); + + // Setup has run but nothing has resolved: the stores are still empty, + // which is exactly the transient state that used to fire the request. + expect(getLibraryInfo).not.toHaveBeenCalled(); + + await flushPromises(); + + expect(getLibraryInfo).not.toHaveBeenCalled(); + expect(wrapper.text()).not.toContain("home.empty-headline"); + }); + + it("fetches the filesystem hint once the library is confirmed empty", async () => { + stubHomeFetches(false); + + const wrapper = mountHome(); + await flushPromises(); + + expect(getLibraryInfo).toHaveBeenCalledTimes(1); + expect(wrapper.text()).toContain("home.empty-headline"); + }); + + it("does not render the empty state before the initial loads settle", async () => { + stubHomeFetches(false); + + const wrapper = mountHome(); + + expect(wrapper.text()).not.toContain("home.empty-headline"); + + await flushPromises(); + + expect(wrapper.text()).toContain("home.empty-headline"); + }); +}); diff --git a/frontend/src/v2/views/Home.vue b/frontend/src/v2/views/Home.vue index ec1e003219..5f7f876161 100644 --- a/frontend/src/v2/views/Home.vue +++ b/frontend/src/v2/views/Home.vue @@ -61,29 +61,45 @@ const fetchingContinue = ref(false); const gridRoot = ref(null); useGridNav(gridRoot); -onMounted(() => { +// Flips once every initial request has settled. Until then the store +// `fetching*` flags are still false and the stores are still empty, so +// `isEmpty` reads true for a library that simply hasn't loaded yet. +const initialLoadDone = ref(false); + +onMounted(async () => { + const initialLoads: Promise[] = []; + if (platformsStore.allPlatforms.length === 0) { - platformsStore.fetchPlatforms(); + initialLoads.push(platformsStore.fetchPlatforms()); } if (collectionsStore.allCollections.length === 0) { - collectionsStore.fetchCollections(); + initialLoads.push(collectionsStore.fetchCollections()); } if (showSmartCollections.value && smartCollections.value.length === 0) { - collectionsStore.fetchSmartCollections(); + initialLoads.push(collectionsStore.fetchSmartCollections()); } if (showVirtualCollections.value && virtualCollections.value.length === 0) { - collectionsStore.fetchVirtualCollections(virtualCollectionType.value); + initialLoads.push( + collectionsStore.fetchVirtualCollections(virtualCollectionType.value), + ); } if (recentRoms.value.length === 0) { fetchingRecent.value = true; - romsStore.fetchRecentRoms().finally(() => (fetchingRecent.value = false)); + initialLoads.push( + romsStore.fetchRecentRoms().finally(() => (fetchingRecent.value = false)), + ); } if (continuePlayingRoms.value.length === 0) { fetchingContinue.value = true; - romsStore - .fetchContinuePlayingRoms() - .finally(() => (fetchingContinue.value = false)); + initialLoads.push( + romsStore + .fetchContinuePlayingRoms() + .finally(() => (fetchingContinue.value = false)), + ); } + + await Promise.allSettled(initialLoads); + initialLoadDone.value = true; }); // True when nothing has been added yet AND we're no longer fetching — @@ -105,6 +121,10 @@ const isEmpty = computed( (!showVirtualCollections.value || virtualCollections.value.length === 0), ); +// Gate on the load having actually happened: `isEmpty` alone is true +// during setup, before any request has been made. +const showEmptyState = computed(() => initialLoadDone.value && isEmpty.value); + // Filesystem snapshot for the empty state — shows the user what RomM // can already see on disk so the "run a scan" CTA isn't a leap of // faith. Fetched lazily the first time the empty state appears; the @@ -137,13 +157,9 @@ async function loadLibraryInfo() { } } -watch( - isEmpty, - (empty) => { - if (empty) void loadLibraryInfo(); - }, - { immediate: true }, -); +watch(showEmptyState, (empty) => { + if (empty) void loadLibraryInfo(); +}); // Favorite ROMs — derived from the Favorites collection's rom_ids. // eslint-disable-next-line @typescript-eslint/no-unused-vars -- false positive: used in