diff --git a/__mocks__/@capacitor/core.ts b/__mocks__/@capacitor/core.ts index 445c1e8d..7794cf7a 100644 --- a/__mocks__/@capacitor/core.ts +++ b/__mocks__/@capacitor/core.ts @@ -3,6 +3,12 @@ import { vi } from "vitest"; export const Capacitor = { isNativePlatform: vi.fn().mockReturnValue(false), getPlatform: vi.fn().mockReturnValue("web"), - isPluginAvailable: vi.fn().mockReturnValue(false), + // Preferences ships a web implementation and is mocked globally, so it is + // available on every platform. Modules loaded by the test setup keep this + // instance even when a test file mocks @capacitor/core as native, so a + // missing Preferences plugin would fail preference hydration there. + isPluginAvailable: vi.fn( + (pluginName: string) => pluginName === "Preferences", + ), convertFileSrc: vi.fn((filePath: string) => filePath), }; diff --git a/src/__tests__/integration/index-route.test.tsx b/src/__tests__/integration/index-route.test.tsx index 062db08f..54d47ec2 100644 --- a/src/__tests__/integration/index-route.test.tsx +++ b/src/__tests__/integration/index-route.test.tsx @@ -200,6 +200,10 @@ vi.mock("@/lib/toastUtils", () => ({ // Mock NFC cancel session vi.mock("@/lib/nfc", () => ({ cancelSession: vi.fn(), + sessionManager: { + setShouldRestart: vi.fn(), + setLaunchOnScan: vi.fn(), + }, })); // Use vi.hoisted for Capacitor mock diff --git a/src/__tests__/unit/components/MediaSearchModal.test.tsx b/src/__tests__/unit/components/MediaSearchModal.test.tsx index 85567671..dd14ddaf 100644 --- a/src/__tests__/unit/components/MediaSearchModal.test.tsx +++ b/src/__tests__/unit/components/MediaSearchModal.test.tsx @@ -27,7 +27,7 @@ vi.mock("@capacitor/app", () => ({ vi.mock("@capacitor/preferences", () => ({ Preferences: { - get: vi.fn().mockResolvedValue({ value: "all" }), + get: vi.fn().mockResolvedValue({ value: null }), set: vi.fn(), }, })); diff --git a/src/__tests__/unit/components/library/FavoriteButton.test.tsx b/src/__tests__/unit/components/library/FavoriteButton.test.tsx index a80a7ccf..05798700 100644 --- a/src/__tests__/unit/components/library/FavoriteButton.test.tsx +++ b/src/__tests__/unit/components/library/FavoriteButton.test.tsx @@ -1,8 +1,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import userEvent from "@testing-library/user-event"; +import { QueryClient } from "@tanstack/react-query"; import { render, screen, waitFor } from "@/test-utils"; import { FavoriteButton } from "@/components/library/FavoriteButton"; import { CoreAPI } from "@/lib/coreApi"; +import { LIBRARY_QUERY_KEYS } from "@/lib/libraryMedia"; import { logger } from "@/lib/logger"; import type { MediaBrowseEntry } from "@/lib/models"; import { useStatusStore } from "@/lib/store"; @@ -68,6 +70,41 @@ describe("FavoriteButton", () => { ).toBeInTheDocument(); }); + it("should refresh letter cursors before browse lists after a change", async () => { + vi.spyOn(CoreAPI, "mediaTagsUpdate").mockResolvedValue({ + tags: [{ type: "user", tag: "favorite" }], + }); + const invalidateSpy = vi.spyOn(QueryClient.prototype, "invalidateQueries"); + const user = userEvent.setup(); + + render( + , + ); + await user.click( + screen.getByRole("button", { name: "library.addFavorite" }), + ); + + await waitFor(() => expect(invalidateSpy).toHaveBeenCalled()); + const invalidated = invalidateSpy.mock.calls.map( + ([filters]) => filters?.queryKey, + ); + const indexAt = invalidated.findIndex( + (key) => key?.[0] === LIBRARY_QUERY_KEYS.browseIndex, + ); + const browseAt = invalidated.findIndex( + (key) => key?.[0] === LIBRARY_QUERY_KEYS.browse, + ); + expect(invalidated[indexAt]).toEqual([ + LIBRARY_QUERY_KEYS.browseIndex, + "device-a", + ]); + expect(indexAt).toBeLessThan(browseAt); + }); + it("should remove a favorite using system and path fallback", async () => { const updateSpy = vi.spyOn(CoreAPI, "mediaTagsUpdate").mockResolvedValue({ tags: [], diff --git a/src/__tests__/unit/components/library/LibraryBrowseList.test.tsx b/src/__tests__/unit/components/library/LibraryBrowseList.test.tsx index 70f2b717..f55b598e 100644 --- a/src/__tests__/unit/components/library/LibraryBrowseList.test.tsx +++ b/src/__tests__/unit/components/library/LibraryBrowseList.test.tsx @@ -1,6 +1,14 @@ import { createRef, type ComponentProps } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { findA11yViolations, fireEvent, render, screen } from "@/test-utils"; +import { useVirtualizer } from "@tanstack/react-virtual"; +import { + act, + findA11yViolations, + fireEvent, + render, + screen, + waitFor, +} from "@/test-utils"; import { LibraryBrowseList, type LibraryBrowseListHandle, @@ -16,14 +24,32 @@ vi.mock("@/hooks/useScreenReaderEnabled", () => ({ useScreenReaderEnabled: () => false, })); +function mockVirtualRows(indices: number[], isScrolling = false) { + vi.mocked(useVirtualizer).mockImplementation( + () => + ({ + getVirtualItems: () => + indices.map((index) => ({ + index, + key: `row-${index}`, + start: index * 88, + size: 88, + })), + getTotalSize: () => 176, + measureElement: vi.fn(), + measure: mockMeasure, + scrollToIndex: mockScrollToIndex, + isScrolling, + range: + indices.length > 0 + ? { startIndex: indices[0]!, endIndex: indices.at(-1)! } + : null, + }) as unknown as ReturnType, + ); +} + vi.mock("@tanstack/react-virtual", () => ({ - useVirtualizer: vi.fn(() => ({ - getVirtualItems: () => [{ index: 0, key: "row-0", start: 0, size: 88 }], - getTotalSize: () => 176, - measureElement: vi.fn(), - measure: mockMeasure, - scrollToIndex: mockScrollToIndex, - })), + useVirtualizer: vi.fn(), })); vi.mock("@/components/library/LibraryArtwork", () => ({ @@ -75,6 +101,7 @@ function renderList( describe("LibraryBrowseList", () => { beforeEach(() => { vi.clearAllMocks(); + mockVirtualRows([0]); useStatusStore.setState({ corePlatform: null }); usePreferencesStore.setState({ accessibleLists: false, @@ -179,4 +206,126 @@ describe("LibraryBrowseList", () => { expect(scrollIntoView).toHaveBeenCalledWith({ block: "start" }); expect(mockScrollToIndex).not.toHaveBeenCalled(); }); + it("should wait for a letter jump to land before loading more rows", () => { + const onFetchMore = vi.fn(); + const view = renderList({ + hasNextPage: true, + interactionDisabled: true, + onFetchMore, + }); + + expect(onFetchMore).not.toHaveBeenCalled(); + + view.rerender( + , + ); + + expect(onFetchMore).toHaveBeenCalledTimes(1); + }); + + it("should request the first visible unloaded row once scrolling settles", () => { + vi.useFakeTimers(); + mockVirtualRows([0, 1, 2]); + const onLoadIndex = vi.fn(); + renderList({ + entries: [undefined, undefined, entries[0]!], + onLoadIndex, + }); + + expect(screen.getAllByRole("listitem")).toHaveLength(1); + expect(onLoadIndex).not.toHaveBeenCalled(); + + act(() => { + vi.advanceTimersByTime(150); + }); + + expect(onLoadIndex).toHaveBeenCalledTimes(1); + expect(onLoadIndex).toHaveBeenCalledWith(0); + }); + + it("should not request unloaded rows while the list is scrolling", () => { + vi.useFakeTimers(); + mockVirtualRows([0, 1, 2], true); + const onLoadIndex = vi.fn(); + renderList({ + entries: [undefined, undefined, entries[0]!], + onLoadIndex, + }); + + act(() => { + vi.advanceTimersByTime(500); + }); + + expect(onLoadIndex).not.toHaveBeenCalled(); + }); + + it("should offer a retry instead of reloading a failed range", () => { + vi.useFakeTimers(); + mockVirtualRows([0, 1, 2]); + const onLoadIndex = vi.fn(); + renderList({ + entries: [undefined, undefined, entries[0]!], + failedRanges: [{ start: 0, end: 2 }], + onLoadIndex, + }); + + act(() => { + vi.advanceTimersByTime(150); + }); + expect(onLoadIndex).not.toHaveBeenCalled(); + + fireEvent.click( + screen.getByRole("button", { name: "library.loadEarlier" }), + ); + expect(onLoadIndex).toHaveBeenCalledWith(1, { retry: true }); + }); + + it("should load earlier rows on request in accessible mode", async () => { + usePreferencesStore.setState({ accessibleLists: true }); + const onLoadIndex = vi.fn(); + const { baseElement, rerender } = renderList({ + entries: [undefined, undefined, entries[1]!], + onLoadIndex, + }); + + expect(await findA11yViolations(baseElement)).toEqual([]); + fireEvent.click( + screen.getByRole("button", { name: "library.loadEarlier" }), + ); + expect(onLoadIndex).toHaveBeenCalledWith(1, { retry: true }); + + rerender( + , + ); + + expect( + screen.queryByRole("button", { name: "library.loadEarlier" }), + ).not.toBeInTheDocument(); + await waitFor(() => + expect(screen.getByRole("button", { name: "Game One" })).toHaveFocus(), + ); + }); }); diff --git a/src/__tests__/unit/hooks/useLibraryBrowse.test.tsx b/src/__tests__/unit/hooks/useLibraryBrowse.test.tsx index f7a81111..b50a9293 100644 --- a/src/__tests__/unit/hooks/useLibraryBrowse.test.tsx +++ b/src/__tests__/unit/hooks/useLibraryBrowse.test.tsx @@ -1,8 +1,19 @@ +import { useState } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { act, renderHook, waitFor } from "@/test-utils"; -import { CoreAPI } from "@/lib/coreApi"; +import { + act, + createProvidersWithQueryClient, + createTestQueryClient, + renderHook, + waitFor, +} from "@/test-utils"; +import { CoreAPI, CoreApiError } from "@/lib/coreApi"; +import { libraryBrowseIndexQueryOptions } from "@/lib/libraryBrowse"; +import { LIBRARY_QUERY_KEYS } from "@/lib/libraryMedia"; +import type { LibraryBrowseWindow } from "@/lib/librarySessionStore"; import type { MediaBrowseEntry, + MediaBrowseIndexResponse, MediaBrowseParams, MediaBrowseResponse, } from "@/lib/models"; @@ -18,6 +29,107 @@ function entries(start: number, count: number): MediaBrowseEntry[] { })); } +const SCOPE = { + deviceKey: "device-a", + systemId: "SNES", + path: "/roms/SNES", + sort: "name-asc" as const, +}; + +const INDEX: MediaBrowseIndexResponse = { + scheme: "latin", + totalFiles: 1700, + groups: [ + { key: "A", label: "A", count: 400, cursor: "", offset: 0 }, + { key: "S", label: "S", count: 1100, cursor: "letter-s", offset: 400 }, + { key: "T", label: "T", count: 200, cursor: "letter-t", offset: 1500 }, + ], +}; + +const T_WINDOW: LibraryBrowseWindow = { + anchorKey: "T", + anchorStart: 1502, + totalDirs: 2, + loadedKeys: [], +}; + +function directories(count: number): MediaBrowseEntry[] { + return Array.from({ length: count }, (_, offset) => ({ + name: `Folder ${offset + 1}`, + path: `/roms/SNES/folder-${offset + 1}`, + type: "directory" as const, + fileCount: 2, + })); +} + +function page( + pageEntries: MediaBrowseEntry[], + nextCursor: string | null, + totalDirs?: number, +): MediaBrowseResponse { + return { + path: SCOPE.path, + entries: pageEntries, + totalFiles: 1700, + totalDirs, + pagination: { + hasNextPage: nextCursor !== null, + pageSize: pageEntries.length, + nextCursor, + }, + }; +} + +// Two directories followed by 1700 files, bucketed as in INDEX. +async function browseLibrary(params: MediaBrowseParams) { + const maxResults = params.maxResults ?? 100; + switch (params.cursor) { + case undefined: + return page( + [...directories(2), ...entries(1, maxResults - 2)], + "after-top", + 2, + ); + case "letter-s": + return page(entries(401, maxResults), "letter-s-2"); + case "letter-s-2": + return page(entries(1401, maxResults), "letter-t"); + case "letter-t": + return page(entries(1501, maxResults), "after-t"); + default: + return page([], null); + } +} + +function renderWindowedBrowse(initialWindow: LibraryBrowseWindow | null) { + const queryClient = createTestQueryClient(); + queryClient.setQueryData( + libraryBrowseIndexQueryOptions(SCOPE).queryKey, + INDEX, + ); + const view = renderHook( + () => { + const [browseWindow, setBrowseWindow] = + useState(initialWindow); + const browse = useLibraryBrowse({ + ...SCOPE, + enabled: true, + browseWindow, + updateBrowseWindow: setBrowseWindow, + }); + return { browse, browseWindow }; + }, + { wrapper: createProvidersWithQueryClient(queryClient) }, + ); + return { ...view, queryClient }; +} + +function browseCursors() { + return vi + .mocked(CoreAPI.mediaBrowse) + .mock.calls.map(([params]) => params.cursor); +} + describe("useLibraryBrowse", () => { beforeEach(() => { vi.restoreAllMocks(); @@ -51,7 +163,7 @@ describe("useLibraryBrowse", () => { ); await waitFor(() => - expect(result.current.entries.map((entry) => entry.name)).toEqual([ + expect(result.current.entries.map((entry) => entry?.name)).toEqual([ "Zulu", "Alpha", ]), @@ -110,7 +222,7 @@ describe("useLibraryBrowse", () => { }); await waitFor(() => - expect(result.current.entries.map((entry) => entry.name)).toEqual([ + expect(result.current.entries.map((entry) => entry?.name)).toEqual([ "Game Title", ]), ); @@ -161,56 +273,283 @@ describe("useLibraryBrowse", () => { ); }); - it("should use deterministic page sizes when loading through a jump target", async () => { + it("should start a jumped list at its bucket cursor with earlier rows unloaded", async () => { + vi.spyOn(CoreAPI, "mediaBrowse").mockImplementation(browseLibrary); + const { result } = renderWindowedBrowse(T_WINDOW); + + await waitFor(() => + expect(result.current.browse.entries).toHaveLength(1602), + ); + expect(result.current.browse.entries[1501]).toBeUndefined(); + expect(result.current.browse.entries[1502]?.name).toBe("Game 1501"); + expect(result.current.browse.totalDirs).toBe(2); + expect(CoreAPI.mediaBrowse).toHaveBeenCalledTimes(1); + expect(CoreAPI.mediaBrowse).toHaveBeenCalledWith( + expect.objectContaining({ cursor: "letter-t", maxResults: 100 }), + expect.any(AbortSignal), + ); + }); + + it("should load an earlier bucket once in pages of at most 1000 rows", async () => { + vi.spyOn(CoreAPI, "mediaBrowse").mockImplementation(browseLibrary); + const { result } = renderWindowedBrowse(T_WINDOW); + await waitFor(() => + expect(result.current.browse.entries[1502]).toBeDefined(), + ); + + act(() => result.current.browse.loadIndex(1000)); + + await waitFor(() => + expect(result.current.browse.entries[1501]?.name).toBe("Game 1500"), + ); + expect(result.current.browse.entries[402]?.name).toBe("Game 401"); + expect(result.current.browse.entries[401]).toBeUndefined(); + expect( + vi + .mocked(CoreAPI.mediaBrowse) + .mock.calls.map(([params]) => [params.cursor, params.maxResults]), + ).toEqual([ + ["letter-t", 100], + ["letter-s", 1000], + ["letter-s-2", 100], + ]); + + act(() => result.current.browse.loadIndex(1200)); + expect(result.current.browseWindow?.loadedKeys).toEqual(["S"]); + expect(CoreAPI.mediaBrowse).toHaveBeenCalledTimes(3); + }); + + it("should load the first bucket together with its leading directories", async () => { + vi.spyOn(CoreAPI, "mediaBrowse").mockImplementation(browseLibrary); + const { result } = renderWindowedBrowse(T_WINDOW); + await waitFor(() => + expect(result.current.browse.entries[1502]).toBeDefined(), + ); + + act(() => result.current.browse.loadIndex(0)); + + await waitFor(() => + expect(result.current.browse.entries[401]?.name).toBe("Game 400"), + ); + expect(result.current.browse.entries[0]?.type).toBe("directory"); + expect(result.current.browse.entries[402]).toBeUndefined(); + expect(CoreAPI.mediaBrowse).toHaveBeenLastCalledWith( + expect.objectContaining({ cursor: undefined, maxResults: 402 }), + expect.any(AbortSignal), + ); + }); + + it("should retry an expired bucket cursor from a fresh index", async () => { + const calls: string[] = []; + vi.spyOn(CoreAPI, "mediaBrowse").mockImplementation(async (params) => { + calls.push(`browse:${params.cursor}`); + if (params.cursor === "letter-t") { + throw new CoreApiError( + "library visibility changed; restart browse without cursor", + -32602, + ); + } + if (params.cursor === undefined) return browseLibrary(params); + return page(entries(1501, 100), null); + }); + vi.spyOn(CoreAPI, "mediaBrowseIndex").mockImplementation(async () => { + calls.push("index"); + return { + ...INDEX, + groups: INDEX.groups.map((group) => + group.key === "T" ? { ...group, cursor: "letter-t-2" } : group, + ), + }; + }); + const { result } = renderWindowedBrowse(T_WINDOW); + + await waitFor(() => + expect(result.current.browse.entries[1502]).toBeDefined(), + ); + // The visibility change also re-reads the directory count from the top. + expect(calls).toEqual([ + "browse:letter-t", + "browse:undefined", + "index", + "browse:letter-t-2", + ]); + await waitFor(() => + expect(result.current.browse.isLoadingIndex).toBe(false), + ); + expect(result.current.browseWindow).toEqual(T_WINDOW); + }); + + it("should move a jumped list when a library refresh changes the directory count", async () => { + let directoryCount = 2; + vi.spyOn(CoreAPI, "mediaBrowse").mockImplementation(async (params) => { + if (params.cursor !== undefined) return browseLibrary(params); + const maxResults = params.maxResults ?? 100; + return page( + [ + ...directories(directoryCount), + ...entries(1, Math.max(maxResults - directoryCount, 0)), + ], + "after-top", + directoryCount, + ); + }); + vi.spyOn(CoreAPI, "mediaBrowseIndex").mockResolvedValue(INDEX); + const { result, queryClient } = renderWindowedBrowse(T_WINDOW); + await waitFor(() => + expect(result.current.browse.entries[1502]).toBeDefined(), + ); + act(() => result.current.browse.loadIndex(1000)); + await waitFor(() => + expect(result.current.browse.entries[402]?.name).toBe("Game 401"), + ); + + directoryCount = 5; + await act(async () => { + await queryClient.invalidateQueries({ + queryKey: [LIBRARY_QUERY_KEYS.browseIndex], + }); + await queryClient.invalidateQueries({ + queryKey: [LIBRARY_QUERY_KEYS.browse], + }); + }); + + await waitFor(() => + expect(result.current.browseWindow).toEqual({ + ...T_WINDOW, + anchorStart: 1505, + totalDirs: 5, + loadedKeys: ["S"], + }), + ); + await waitFor(() => + expect(result.current.browse.entries[405]?.name).toBe("Game 401"), + ); + expect(result.current.browse.entries[1505]?.name).toBe("Game 1501"); + expect(result.current.browse.entries[1504]?.name).toBe("Game 1500"); + expect(result.current.browse.totalDirs).toBe(5); + expect(CoreAPI.mediaBrowse).toHaveBeenCalledWith( + expect.objectContaining({ path: SCOPE.path, maxResults: 1 }), + expect.any(AbortSignal), + ); + }); + + it("should refetch a jumped list from its bucket rather than the top", async () => { + vi.spyOn(CoreAPI, "mediaBrowse").mockImplementation(browseLibrary); + const { result } = renderWindowedBrowse(T_WINDOW); + await waitFor(() => + expect(result.current.browse.entries[1502]).toBeDefined(), + ); + vi.spyOn(CoreAPI, "mediaBrowseIndex").mockResolvedValue(INDEX); + + await act(() => result.current.browse.refetch()); + + expect(browseCursors()).toEqual(["letter-t", "letter-t"]); + expect(result.current.browse.entries).toHaveLength(1602); + }); + + it("should return to the top list when a refreshed index drops the anchor", async () => { + vi.spyOn(CoreAPI, "mediaBrowse").mockImplementation(browseLibrary); + const { result, queryClient } = renderWindowedBrowse(T_WINDOW); + await waitFor(() => + expect(result.current.browse.entries[1502]).toBeDefined(), + ); + + act(() => { + queryClient.setQueryData(libraryBrowseIndexQueryOptions(SCOPE).queryKey, { + ...INDEX, + groups: INDEX.groups.filter((group) => group.key !== "T"), + }); + }); + + await waitFor(() => expect(result.current.browseWindow).toBeNull()); + await waitFor(() => + expect(result.current.browse.entries).toHaveLength(100), + ); + }); + + it("should keep a failed bucket unloaded until it is retried", async () => { + let bucketFails = true; + vi.spyOn(CoreAPI, "mediaBrowse").mockImplementation(async (params) => { + if (params.cursor === "letter-s" && bucketFails) { + throw new Error("bucket failed"); + } + return browseLibrary(params); + }); + const { result } = renderWindowedBrowse(T_WINDOW); + await waitFor(() => + expect(result.current.browse.entries[1502]).toBeDefined(), + ); + + act(() => result.current.browse.loadIndex(1000)); + + await waitFor(() => + expect(result.current.browse.failedRanges).toEqual([ + { start: 402, end: 1502 }, + ]), + ); + const failedCalls = vi.mocked(CoreAPI.mediaBrowse).mock.calls.length; + act(() => result.current.browse.loadIndex(1000)); + expect(CoreAPI.mediaBrowse).toHaveBeenCalledTimes(failedCalls); + + bucketFails = false; + act(() => result.current.browse.loadIndex(1000, { retry: true })); + + await waitFor(() => + expect(result.current.browse.entries[402]?.name).toBe("Game 401"), + ); + expect(result.current.browse.failedRanges).toEqual([]); + }); + + it("should jump from the top list with a single bucket cursor request", async () => { + vi.spyOn(CoreAPI, "mediaBrowse").mockImplementation(browseLibrary); + const { result } = renderWindowedBrowse(null); + await waitFor(() => + expect(result.current.browse.entries).toHaveLength(100), + ); + + const tGroup = INDEX.groups[2]!; + let jump: Awaited>; + await act(async () => { + jump = await result.current.browse.jumpToGroup(tGroup); + }); + expect(jump!).toEqual({ + targetIndex: 1502, + anchor: T_WINDOW, + }); + expect(result.current.browseWindow).toBeNull(); + + act(() => result.current.browse.commitJump(jump)); + + await waitFor(() => + expect(result.current.browse.entries[1502]?.name).toBe("Game 1501"), + ); + expect(browseCursors()).toEqual([undefined, "letter-t"]); + }); + + it("should page through leading directories when jumping to the first bucket", async () => { const requestedLimits: number[] = []; vi.spyOn(CoreAPI, "mediaBrowse").mockImplementation( async (params: MediaBrowseParams) => { requestedLimits.push(params.maxResults ?? 0); - if (!params.cursor) { - return { - path: "/roms/SNES", - entries: entries(1, 2), - totalFiles: 1500, - pagination: { - hasNextPage: true, - pageSize: 100, - nextCursor: "next-1", - }, - }; - } - return { - path: "/roms/SNES", - entries: entries(3, params.maxResults ?? 0), - totalFiles: 1500, - pagination: { - hasNextPage: false, - pageSize: params.maxResults ?? 0, - nextCursor: null, - }, - }; + return params.cursor + ? page([...directories(50), ...entries(1, 50)], null, 150) + : page(directories(100), "more-directories", 150); }, ); - const { result } = renderHook(() => - useLibraryBrowse({ - deviceKey: "device-a", - systemId: "SNES", - path: "/roms/SNES", - enabled: true, - }), + const { result } = renderWindowedBrowse(null); + await waitFor(() => + expect(result.current.browse.entries).toHaveLength(100), ); - await waitFor(() => expect(result.current.entries).toHaveLength(2)); - let loaded = false; + let jump: Awaited>; await act(async () => { - loaded = await result.current.loadThroughIndex(100); + jump = await result.current.browse.jumpToGroup(INDEX.groups[0]!); }); - expect(loaded).toBe(true); + expect(jump!).toEqual({ targetIndex: 150 }); expect(requestedLimits).toEqual([100, 100]); - await waitFor(() => expect(result.current.entries).toHaveLength(102)); - - await act(() => result.current.refetch()); - - expect(requestedLimits).toEqual([100, 100, 100, 100]); + await waitFor(() => + expect(result.current.browse.entries).toHaveLength(200), + ); }); }); diff --git a/src/__tests__/unit/hooks/useScreenReaderEnabled.test.ts b/src/__tests__/unit/hooks/useScreenReaderEnabled.test.ts index 7a359356..bccbfb5e 100644 --- a/src/__tests__/unit/hooks/useScreenReaderEnabled.test.ts +++ b/src/__tests__/unit/hooks/useScreenReaderEnabled.test.ts @@ -43,6 +43,48 @@ describe("useScreenReaderEnabled", () => { }); }); + it("should start a remount in the last detected state", async () => { + vi.mocked(Capacitor.isNativePlatform).mockReturnValue(true); + vi.mocked(ScreenReader.isEnabled).mockResolvedValue({ value: false }); + const view = renderHook(() => useScreenReaderEnabled()); + await waitFor(() => expect(view.result.current).toBe(false)); + view.unmount(); + + // A remounted list must not flash the accessible layout, which has a + // different height than the virtualized one its scroll was saved from. + vi.mocked(ScreenReader.isEnabled).mockReturnValue(new Promise(() => {})); + const { result } = renderHook(() => useScreenReaderEnabled()); + + expect(result.current).toBe(false); + }); + + it("should ignore a detection that resolves after unmount", async () => { + vi.mocked(Capacitor.isNativePlatform).mockReturnValue(true); + let resolveStale: (state: { value: boolean }) => void = () => {}; + vi.mocked(ScreenReader.isEnabled).mockReturnValueOnce( + new Promise((resolve) => { + resolveStale = resolve; + }), + ); + const { unmount: unmountStale } = renderHook(() => + useScreenReaderEnabled(), + ); + unmountStale(); + + vi.mocked(ScreenReader.isEnabled).mockResolvedValue({ value: false }); + const { result: current, unmount } = renderHook(() => + useScreenReaderEnabled(), + ); + await waitFor(() => expect(current.current).toBe(false)); + await act(async () => resolveStale({ value: true })); + unmount(); + + vi.mocked(ScreenReader.isEnabled).mockReturnValue(new Promise(() => {})); + const { result } = renderHook(() => useScreenReaderEnabled()); + + expect(result.current).toBe(false); + }); + it("should subscribe to state changes on native platform", async () => { vi.mocked(Capacitor.isNativePlatform).mockReturnValue(true); vi.mocked(ScreenReader.isEnabled).mockResolvedValue({ value: false }); diff --git a/src/__tests__/unit/lib/librarySessionStore.test.ts b/src/__tests__/unit/lib/librarySessionStore.test.ts index 5c524706..0ac478af 100644 --- a/src/__tests__/unit/lib/librarySessionStore.test.ts +++ b/src/__tests__/unit/lib/librarySessionStore.test.ts @@ -1,9 +1,20 @@ import { beforeEach, describe, expect, it } from "vitest"; import { + forgetLibraryBrowse, + type LibraryBrowseWindow, libraryBrowseScrollKey, librarySearchScrollKey, useLibrarySessionStore, } from "@/lib/librarySessionStore"; +import { useTabSessionStore } from "@/lib/tabSessionStore"; + +const BROWSE_KEY = libraryBrowseScrollKey("SNES", "name-asc", "/roms/SNES"); +const T_WINDOW: LibraryBrowseWindow = { + anchorKey: "T", + anchorStart: 502, + totalDirs: 2, + loadedKeys: [], +}; describe("librarySessionStore navigation", () => { it("should build stable scroll keys for folder and search history", () => { @@ -74,4 +85,45 @@ describe("librarySessionStore navigation", () => { expect(state.embeddedSearchOpen).toEqual({}); expect(state.searches).toEqual({}); }); + + it("should apply browse window updates to the latest window", () => { + const store = useLibrarySessionStore.getState(); + store.updateBrowseWindow(BROWSE_KEY, () => T_WINDOW); + store.updateBrowseWindow(BROWSE_KEY, (current) => + current ? { ...current, loadedKeys: [...current.loadedKeys, "S"] } : null, + ); + store.updateBrowseWindow(BROWSE_KEY, (current) => + current ? { ...current, loadedKeys: [...current.loadedKeys, "R"] } : null, + ); + + expect( + useLibrarySessionStore.getState().browseWindows[BROWSE_KEY]?.loadedKeys, + ).toEqual(["S", "R"]); + }); + + it("should forget a folder's scroll and jump window together", () => { + useTabSessionStore.getState().reset(); + useTabSessionStore.getState().rememberScroll(BROWSE_KEY, 0, 4400); + useLibrarySessionStore + .getState() + .updateBrowseWindow(BROWSE_KEY, () => T_WINDOW); + + forgetLibraryBrowse("SNES", "name-asc", "/roms/SNES"); + + expect(useLibrarySessionStore.getState().browseWindows).toEqual({}); + expect(useTabSessionStore.getState().scrollPositions[BROWSE_KEY]).toBe( + undefined, + ); + }); + + it("should clear jump windows with navigation and device changes", () => { + const store = useLibrarySessionStore.getState(); + store.updateBrowseWindow(BROWSE_KEY, () => T_WINDOW); + store.resetNavigation(); + expect(useLibrarySessionStore.getState().browseWindows).toEqual({}); + + store.updateBrowseWindow(BROWSE_KEY, () => T_WINDOW); + store.activateDevice("device-b"); + expect(useLibrarySessionStore.getState().browseWindows).toEqual({}); + }); }); diff --git a/src/__tests__/unit/routes/create.search.loader.test.ts b/src/__tests__/unit/routes/create.search.loader.test.ts index 508ae7a4..6400733a 100644 --- a/src/__tests__/unit/routes/create.search.loader.test.ts +++ b/src/__tests__/unit/routes/create.search.loader.test.ts @@ -3,7 +3,8 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; // Mock dependencies vi.mock("@capacitor/preferences", () => ({ Preferences: { - get: vi.fn(), + get: vi.fn().mockResolvedValue({ value: null }), + set: vi.fn().mockResolvedValue(undefined), }, })); diff --git a/src/__tests__/unit/routes/library.system.test.tsx b/src/__tests__/unit/routes/library.system.test.tsx index 4299ce89..fa78bd5d 100644 --- a/src/__tests__/unit/routes/library.system.test.tsx +++ b/src/__tests__/unit/routes/library.system.test.tsx @@ -2,7 +2,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import userEvent from "@testing-library/user-event"; import { act, fireEvent, render, screen, waitFor, within } from "@/test-utils"; import { CoreAPI } from "@/lib/coreApi"; -import type { MediaBrowseEntry, MediaBrowseParams } from "@/lib/models"; +import type { + MediaBrowseEntry, + MediaBrowseIndexResponse, + MediaBrowseParams, + MediaBrowseResponse, +} from "@/lib/models"; import { useStatusStore } from "@/lib/store"; import { usePreferencesStore } from "@/lib/preferencesStore"; import { @@ -13,13 +18,19 @@ import { useTabSessionStore } from "@/lib/tabSessionStore"; import { LibrarySystem } from "@/routes/library.$system"; import { seedActiveDevice } from "@/test-utils/deviceRegistry"; -const { mockNavigate, mockOutOfRangeScroll, mockScrollToIndex } = vi.hoisted( - () => ({ - mockNavigate: vi.fn(), - mockOutOfRangeScroll: vi.fn(), - mockScrollToIndex: vi.fn(), - }), -); +const { + mockErrorToast, + mockNavigate, + mockOutOfRangeScroll, + mockScrollToIndex, + mockVirtualScroll, +} = vi.hoisted(() => ({ + mockErrorToast: vi.fn(), + mockNavigate: vi.fn(), + mockOutOfRangeScroll: vi.fn(), + mockScrollToIndex: vi.fn(), + mockVirtualScroll: { startIndex: 0 }, +})); vi.mock("@tanstack/react-router", async (importOriginal) => { const actual = @@ -33,22 +44,37 @@ vi.mock("@tanstack/react-router", async (importOriginal) => { }); vi.mock("@tanstack/react-virtual", () => ({ - useVirtualizer: ({ count }: { count: number }) => ({ - getVirtualItems: () => - Array.from({ length: Math.min(count, 5) }, (_, index) => ({ - key: index, - index, - size: 88, - start: index * 88, - })), - getTotalSize: () => count * 88, - measureElement: vi.fn(), - measure: vi.fn(), - scrollToIndex: (index: number, options: { align: string }) => { - if (index >= count) mockOutOfRangeScroll(index, count); - mockScrollToIndex(index, options); - }, - }), + useVirtualizer: ({ count }: { count: number }) => { + const startIndex = Math.max( + 0, + Math.min(mockVirtualScroll.startIndex, count - 1), + ); + const length = Math.max(0, Math.min(count - startIndex, 5)); + return { + getVirtualItems: () => + Array.from({ length }, (_, offset) => ({ + key: startIndex + offset, + index: startIndex + offset, + size: 88, + start: (startIndex + offset) * 88, + })), + getTotalSize: () => count * 88, + measureElement: vi.fn(), + measure: vi.fn(), + isScrolling: false, + range: + length > 0 ? { startIndex, endIndex: startIndex + length - 1 } : null, + scrollToIndex: (index: number, options: { align: string }) => { + if (index >= count) mockOutOfRangeScroll(index, count); + mockVirtualScroll.startIndex = index; + mockScrollToIndex(index, options); + }, + }; + }, +})); + +vi.mock("@/lib/toastUtils", () => ({ + showRateLimitedErrorToast: mockErrorToast, })); vi.mock("@/hooks/usePageHeadingFocus", () => ({ @@ -97,6 +123,107 @@ function browseResult(path: string, entries: MediaBrowseEntry[]) { }; } +const ARCADE_ROOT = mediaEntry({ + mediaId: undefined, + name: "Arcade", + path: "/media/fat/_Arcade", + type: "root", +}); + +const LETTER_INDEX: MediaBrowseIndexResponse = { + scheme: "latin", + totalFiles: 600, + groups: [ + { key: "A", label: "A", count: 400, cursor: "", offset: 0 }, + { key: "S", label: "S", count: 100, cursor: "letter-s", offset: 400 }, + { key: "T", label: "T", count: 100, cursor: "letter-t", offset: 500 }, + ], +}; + +function arcadeFiles(first: number, count: number): MediaBrowseEntry[] { + return Array.from({ length: count }, (_, offset) => + mediaEntry({ + mediaId: first + offset, + name: `Game ${first + offset}`, + path: `/media/fat/_Arcade/game-${first + offset}.mra`, + }), + ); +} + +// Two directories followed by 600 files, bucketed as in LETTER_INDEX. +async function browseArcade( + params: MediaBrowseParams, +): Promise { + if (!params.path) return browseResult("", [ARCADE_ROOT]); + const listing = (entries: MediaBrowseEntry[], nextCursor: string | null) => ({ + path: params.path ?? "", + entries, + totalFiles: 600, + totalDirs: params.cursor ? 0 : 2, + pagination: { + hasNextPage: nextCursor !== null, + pageSize: params.maxResults ?? 100, + nextCursor, + }, + }); + const maxResults = params.maxResults ?? 100; + switch (params.cursor) { + case undefined: + return listing( + [ + mediaEntry({ + mediaId: undefined, + name: "Bootlegs", + path: "/media/fat/_Arcade/Bootlegs", + type: "directory", + fileCount: 3, + }), + mediaEntry({ + mediaId: undefined, + name: "Hacks", + path: "/media/fat/_Arcade/Hacks", + type: "directory", + fileCount: 3, + }), + ...arcadeFiles(1, maxResults - 2), + ], + "next-page", + ); + case "letter-s": + return listing(arcadeFiles(401, maxResults), "letter-t"); + case "letter-t": + return listing(arcadeFiles(501, maxResults), null); + default: + return listing(arcadeFiles(99, maxResults), "next-page"); + } +} + +function arcadeBrowseCursors() { + return vi + .mocked(CoreAPI.mediaBrowse) + .mock.calls.filter(([params]) => params.path === ARCADE_ROOT.path) + .map(([params]) => params.cursor); +} + +async function chooseLetter( + user: ReturnType, + label: string, +) { + await user.click( + await screen.findByRole("button", { name: "library.optionsTitle" }), + ); + await user.click( + within( + screen.getByRole("dialog", { name: "library.optionsTitle" }), + ).getByRole("button", { name: "library.goToTitle" }), + ); + await user.click( + within( + await screen.findByRole("dialog", { name: "library.goToTitle" }), + ).getByRole("button", { name: `${label} library.itemCount` }), + ); +} + function metadataResult(name: string, path: string) { return { media: { @@ -125,6 +252,8 @@ describe("Library system browser", () => { mockNavigate.mockReset(); mockOutOfRangeScroll.mockReset(); mockScrollToIndex.mockReset(); + mockErrorToast.mockReset(); + mockVirtualScroll.startIndex = 0; useLibrarySessionStore.getState().reset(); useTabSessionStore.getState().reset(); usePreferencesStore.setState({ @@ -606,91 +735,113 @@ describe("Library system browser", () => { ); }); - it("should wait for distant letter rows before scrolling", async () => { - const folder = mediaEntry({ - mediaId: undefined, - name: "Arcade", - path: "/media/fat/_Arcade", - type: "root", - }); - const firstPage = Array.from({ length: 100 }, (_, index) => - mediaEntry({ mediaId: index + 1, name: `Game ${index + 1}` }), - ); - const jumpPage = Array.from({ length: 500 }, (_, index) => - mediaEntry({ mediaId: index + 101, name: `Game ${index + 101}` }), - ); - const browseSpy = vi - .spyOn(CoreAPI, "mediaBrowse") - .mockImplementation(async (params: MediaBrowseParams) => { - if (!params.path) return browseResult("", [folder]); - if (!params.cursor) { - return { - ...browseResult(params.path, firstPage), - totalFiles: 600, - totalDirs: 0, - pagination: { - hasNextPage: true, - pageSize: 100, - nextCursor: "next-page", - }, - }; - } - return { - ...browseResult(params.path, jumpPage), - totalFiles: 600, - totalDirs: 0, - pagination: { - hasNextPage: false, - pageSize: 500, - nextCursor: null, - }, - }; - }); - vi.spyOn(CoreAPI, "mediaBrowseIndex").mockResolvedValue({ - scheme: "latin", - totalFiles: 600, - groups: [ - { - key: "T", - label: "T", - count: 100, - cursor: "letter-t", - offset: 500, - }, - ], - }); - const user = userEvent.setup(); + it("should jump to a distant letter with its index cursor", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + vi.spyOn(CoreAPI, "mediaBrowse").mockImplementation(browseArcade); + vi.spyOn(CoreAPI, "mediaBrowseIndex").mockResolvedValue(LETTER_INDEX); + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); render(); - await user.click( - await screen.findByRole("button", { name: "library.optionsTitle" }), - ); - await user.click( - within( - screen.getByRole("dialog", { name: "library.optionsTitle" }), - ).getByRole("button", { name: "library.goToTitle" }), - ); - await user.click( - within( - await screen.findByRole("dialog", { name: "library.goToTitle" }), - ).getByRole("button", { name: "T library.itemCount" }), - ); + await chooseLetter(user, "T"); await waitFor(() => - expect(mockScrollToIndex).toHaveBeenCalledWith(500, { + expect(mockScrollToIndex).toHaveBeenCalledWith(502, { align: "start", }), ); expect(mockOutOfRangeScroll).not.toHaveBeenCalled(); - expect(browseSpy).toHaveBeenCalledWith( - expect.objectContaining({ - cursor: "next-page", - maxResults: 100, + expect( + screen.getByRole("button", { name: "Game 501" }), + ).toBeInTheDocument(); + + // Rows around the target are loaded, so settling does not fetch a bucket. + await act(() => vi.advanceTimersByTimeAsync(500)); + expect(arcadeBrowseCursors()).toEqual([undefined, "letter-t"]); + expect(CoreAPI.mediaBrowse).toHaveBeenCalledWith( + expect.objectContaining({ cursor: "letter-t", maxResults: 100 }), + expect.any(AbortSignal), + ); + }); + + it("should load an earlier letter when a restored jump shows its rows", async () => { + const session = useLibrarySessionStore.getState(); + session.activateDevice("device-a"); + session.setFolderLevels("SNES", [ + { name: "Arcade", path: ARCADE_ROOT.path }, + ]); + session.updateBrowseWindow( + libraryBrowseScrollKey("SNES", "name-asc", ARCADE_ROOT.path), + () => ({ + anchorKey: "T", + anchorStart: 502, + totalDirs: 2, + loadedKeys: [], }), + ); + mockVirtualScroll.startIndex = 499; + vi.spyOn(CoreAPI, "mediaBrowse").mockImplementation(browseArcade); + vi.spyOn(CoreAPI, "mediaBrowseIndex").mockResolvedValue(LETTER_INDEX); + + render(); + + expect( + await screen.findByRole("button", { name: "Game 500" }), + ).toBeInTheDocument(); + expect(arcadeBrowseCursors()).toEqual(["letter-t", "letter-s"]); + expect(CoreAPI.mediaBrowse).toHaveBeenLastCalledWith( + expect.objectContaining({ cursor: "letter-s", maxResults: 100 }), expect.any(AbortSignal), ); }); + it("should not keep a jump that finishes after leaving the folder", async () => { + let finishJump: (response: MediaBrowseResponse) => void = () => {}; + vi.spyOn(CoreAPI, "mediaBrowse").mockImplementation(async (params) => + params.cursor === "letter-t" + ? new Promise((resolve) => { + finishJump = resolve; + }) + : browseArcade(params), + ); + vi.spyOn(CoreAPI, "mediaBrowseIndex").mockResolvedValue(LETTER_INDEX); + const user = userEvent.setup(); + + render(); + await chooseLetter(user, "T"); + await waitFor(() => expect(arcadeBrowseCursors()).toContain("letter-t")); + await user.click(screen.getByRole("button", { name: "nav.back" })); + + await act(async () => { + finishJump( + await browseArcade({ path: ARCADE_ROOT.path, cursor: "letter-t" }), + ); + }); + + expect(useLibrarySessionStore.getState().browseWindows).toEqual({}); + expect(mockScrollToIndex).not.toHaveBeenCalled(); + }); + + it("should report a letter jump whose cursor page fails", async () => { + vi.spyOn(CoreAPI, "mediaBrowse").mockImplementation(async (params) => { + if (params.cursor === "letter-t") throw new Error("browse failed"); + return browseArcade(params); + }); + vi.spyOn(CoreAPI, "mediaBrowseIndex").mockResolvedValue(LETTER_INDEX); + const user = userEvent.setup(); + + render(); + await chooseLetter(user, "T"); + + await waitFor(() => + expect(mockErrorToast).toHaveBeenCalledWith("library.jumpError"), + ); + expect(useLibrarySessionStore.getState().browseWindows).toEqual({}); + expect(mockScrollToIndex).not.toHaveBeenCalled(); + expect( + screen.getByRole("button", { name: "library.optionsTitle" }), + ).toBeEnabled(); + }); + it("should use browse index groups to jump within loaded rows", async () => { const folder = mediaEntry({ mediaId: undefined, diff --git a/src/components/ConnectionProvider.tsx b/src/components/ConnectionProvider.tsx index 79696708..d200feb1 100644 --- a/src/components/ConnectionProvider.tsx +++ b/src/components/ConnectionProvider.tsx @@ -617,10 +617,10 @@ export function ConnectionProvider({ children }: ConnectionProviderProps) { queryKey: [LIBRARY_QUERY_KEYS.favorites], }); queryClient.invalidateQueries({ - queryKey: [LIBRARY_QUERY_KEYS.browse], + queryKey: [LIBRARY_QUERY_KEYS.browseIndex], }); queryClient.invalidateQueries({ - queryKey: [LIBRARY_QUERY_KEYS.browseIndex], + queryKey: [LIBRARY_QUERY_KEYS.browse], }); queryClient.invalidateQueries({ queryKey: [LIBRARY_QUERY_KEYS.meta], @@ -787,10 +787,10 @@ export function ConnectionProvider({ children }: ConnectionProviderProps) { queryKey: [LIBRARY_QUERY_KEYS.favorites], }); queryClient.invalidateQueries({ - queryKey: [LIBRARY_QUERY_KEYS.browse], + queryKey: [LIBRARY_QUERY_KEYS.browseIndex], }); queryClient.invalidateQueries({ - queryKey: [LIBRARY_QUERY_KEYS.browseIndex], + queryKey: [LIBRARY_QUERY_KEYS.browse], }); queryClient.invalidateQueries({ queryKey: [LIBRARY_QUERY_KEYS.meta], @@ -1156,10 +1156,10 @@ export function ConnectionProvider({ children }: ConnectionProviderProps) { queryKey: [LIBRARY_QUERY_KEYS.favorites], }); queryClient.invalidateQueries({ - queryKey: [LIBRARY_QUERY_KEYS.browse], + queryKey: [LIBRARY_QUERY_KEYS.browseIndex], }); queryClient.invalidateQueries({ - queryKey: [LIBRARY_QUERY_KEYS.browseIndex], + queryKey: [LIBRARY_QUERY_KEYS.browse], }); queryClient.invalidateQueries({ queryKey: [LIBRARY_QUERY_KEYS.meta], diff --git a/src/components/library/FavoriteButton.tsx b/src/components/library/FavoriteButton.tsx index f1eaeb06..79d80e4b 100644 --- a/src/components/library/FavoriteButton.tsx +++ b/src/components/library/FavoriteButton.tsx @@ -73,6 +73,11 @@ export function FavoriteButton(props: { queryClient.invalidateQueries({ queryKey: [LIBRARY_QUERY_KEYS.favorites, props.deviceKey], }), + // Preference edits expire bucket cursors; invalidate the index first so + // browse refetches that start from a bucket read a fresh one. + queryClient.invalidateQueries({ + queryKey: [LIBRARY_QUERY_KEYS.browseIndex, props.deviceKey], + }), queryClient.invalidateQueries({ queryKey: [LIBRARY_QUERY_KEYS.browse, props.deviceKey], }), diff --git a/src/components/library/LibraryBrowseList.tsx b/src/components/library/LibraryBrowseList.tsx index 630a696a..f991495d 100644 --- a/src/components/library/LibraryBrowseList.tsx +++ b/src/components/library/LibraryBrowseList.tsx @@ -6,7 +6,7 @@ import { useRef, useState, } from "react"; -import type { RefObject } from "react"; +import type { ReactNode, RefObject } from "react"; import { useVirtualizer } from "@tanstack/react-virtual"; import { FolderIcon, Heart } from "lucide-react"; import { useTranslation } from "react-i18next"; @@ -22,10 +22,12 @@ import { libraryEntryDisplayName, libraryEntryTags, } from "@/lib/libraryMedia"; +import type { LibraryBrowseRange } from "@/lib/libraryBrowse"; import type { MediaBrowseEntry } from "@/lib/models"; import { NextIcon } from "@/lib/images"; import { useSystemNameResolver } from "@/hooks/useSystemName"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; +import { Skeleton } from "@/components/ui/skeleton"; import { Button } from "@/components/wui/Button"; import { useAnnouncer } from "@/components/A11yAnnouncer"; import { useHapticPress } from "@/hooks/useHapticPress"; @@ -35,6 +37,38 @@ import { TagList } from "@/components/TagList"; import { LibraryArtwork } from "@/components/library/LibraryArtwork"; const ROW_HEIGHT = 88; +// Matches the virtualizer's scroll-end delay so jumps and flings settle before +// unloaded rows are requested. +const LOAD_INDEX_DELAY_MS = 150; + +function inRanges( + ranges: readonly LibraryBrowseRange[] | undefined, + index: number, +): boolean { + return ( + ranges?.some((range) => index >= range.start && index < range.end) ?? false + ); +} + +function LibraryBrowsePlaceholderRow(props: { + textZoomed: boolean; + minHeight: number; + hasDivider: boolean; +}) { + return ( + + ); +} function AdaptiveLibraryTitle({ title, @@ -225,7 +259,8 @@ export interface LibraryBrowseListHandle { export const LibraryBrowseList = forwardRef< LibraryBrowseListHandle, { - entries: MediaBrowseEntry[]; + /** Absolute rows; `undefined` marks a row that has not loaded yet. */ + entries: readonly (MediaBrowseEntry | undefined)[]; systemId: string; deviceKey: string; scrollRef: RefObject; @@ -235,6 +270,9 @@ export const LibraryBrowseList = forwardRef< interactionDisabled: boolean; onFetchMore: () => void; onSelect: (entry: MediaBrowseEntry) => void; + onLoadIndex?: (index: number, options?: { retry?: boolean }) => void; + isLoadingIndex?: boolean; + failedRanges?: readonly LibraryBrowseRange[]; showSystemName?: boolean; ariaLabel?: string; } @@ -251,6 +289,7 @@ export const LibraryBrowseList = forwardRef< const rowHeight = Math.ceil(baseRowHeight * Math.max(1, textZoomLevel)); const rowRefs = useRef(new Map()); const loadMoreStartRef = useRef(null); + const loadEarlierRangeRef = useRef(null); const initialScrollOffset = useInitialPageScrollOffset(); // eslint-disable-next-line react-hooks/incompatible-library -- TanStack Virtual exposes imperative functions that React Compiler cannot memoize safely. const virtualizer = useVirtualizer({ @@ -261,7 +300,9 @@ export const LibraryBrowseList = forwardRef< initialOffset: initialScrollOffset, }); const virtualItems = virtualizer.getVirtualItems(); + const isScrolling = virtualizer.isScrolling; const fetchMore = props.onFetchMore; + const loadIndex = props.onLoadIndex; useLayoutEffect(() => { // Cached route data and text zoom can change measured row dimensions after @@ -305,8 +346,11 @@ export const LibraryBrowseList = forwardRef< useEffect(() => { const lastItem = virtualItems.at(-1); + // A jump to an earlier bucket can shorten the list before it scrolls to + // the target, leaving the old position at the new end for a frame. if ( !accessibleLists && + !props.interactionDisabled && lastItem && lastItem.index >= props.entries.length - 5 && props.hasNextPage && @@ -319,10 +363,81 @@ export const LibraryBrowseList = forwardRef< fetchMore, props.entries.length, props.hasNextPage, + props.interactionDisabled, props.isFetchingNextPage, virtualItems, ]); + useEffect(() => { + const range = loadEarlierRangeRef.current; + if (!range) return; + + let firstLoaded: number | null = null; + let loadedCount = 0; + for (let index = range.start; index < range.end; index++) { + if (props.entries[index] === undefined) continue; + firstLoaded ??= index; + loadedCount++; + } + if (firstLoaded === null) return; + + announce(t("library.loadedMore", { count: loadedCount })); + const focusIndex = firstLoaded; + requestAnimationFrame(() => { + rowRefs.current + .get(focusIndex) + ?.querySelector("button") + ?.focus(); + }); + loadEarlierRangeRef.current = null; + }, [announce, props.entries, t]); + + useEffect(() => { + if ( + !loadIndex || + accessibleLists || + isScrolling || + props.interactionDisabled || + props.isLoadingIndex + ) { + return; + } + + // Wait for scrolling to settle and read the visible range without overscan, + // so rows passed during a fling or next to a jump target are not fetched. + const timer = setTimeout(() => { + if (virtualizer.isScrolling || !virtualizer.range) return; + const lastVisible = Math.min( + virtualizer.range.endIndex, + props.entries.length - 1, + ); + for ( + let index = virtualizer.range.startIndex; + index <= lastVisible; + index++ + ) { + if ( + props.entries[index] === undefined && + !inRanges(props.failedRanges, index) + ) { + loadIndex(index); + return; + } + } + }, LOAD_INDEX_DELAY_MS); + return () => clearTimeout(timer); + }, [ + accessibleLists, + isScrolling, + loadIndex, + props.entries, + props.failedRanges, + props.interactionDisabled, + props.isLoadingIndex, + virtualItems, + virtualizer, + ]); + const renderEntry = ( entry: MediaBrowseEntry, index: number, @@ -346,28 +461,71 @@ export const LibraryBrowseList = forwardRef< ); const setSize = props.hasNextPage ? -1 : props.entries.length; + const renderLoadEarlier = (range: LibraryBrowseRange) => ( +