feat(web): every paged list loads as you scroll; Liked becomes tabs (#5416)
release / go (push) Successful in 1m53s
release / govulncheck (push) Successful in 49s
release / web (push) Successful in 1m50s
release / android (push) Successful in 6m44s
release / Build signed APK (releases and dev) (push) Successful in 6m30s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / integration (push) Canceled after 21m41s

Preference 172: no "Load more" buttons. Eight remained, on Liked (three
sections), the three Search overflow pages, Genres and Years.

- ListContinuation: the one bottom-of-list block. It loads the next page
  ahead of the reader, announces "Loading more…" through aria-live, shows an
  optional end line, and on a failed page shows the error with Try again,
  dropping the sentinel so a dead endpoint isn't re-hit on every scroll.
  Used on all ten paged lists, including the four that already autoloaded.
- A failed next page no longer replaces the list. TanStack sets isError for
  it, so the page-level error branches now apply only to a failed first
  load. Genres and Years did the same with their own loader; a later
  page's failure now keeps the grid.
- Liked: Artists | Albums | Tracks tabs (operator's choice). Stacked, a
  long Artists list loading as you scroll would bury the other two. It
  opens on the first tab that has likes.
- TabStrip: the in-page tab strip, now shared by Liked, Playback errors
  and Requests.
- test-utils/intersectionObserver: a stand-in so tests can scroll to the end.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 16:55:33 -04:00
co-authored by Claude Opus 5.5
parent 2205b2927b
commit 0f9ddbca97
24 changed files with 593 additions and 344 deletions
@@ -0,0 +1,40 @@
import { vi } from 'vitest';
// jsdom doesn't ship IntersectionObserver, so a list that loads as you scroll
// (InfiniteScrollSentinel, ListContinuation) never fires in a test. This
// installs a stand-in that records each observer, so a test can play
// "the reader scrolled to the bottom" with scrollToEnd().
type Observer = { cb: IntersectionObserverCallback; self: IntersectionObserver; connected: boolean };
export function installIntersectionObserverMock() {
const observers: Observer[] = [];
class MockIntersectionObserver {
observe = vi.fn();
unobserve = vi.fn();
disconnect = vi.fn(() => {
const o = observers.find((x) => x.self === (this as unknown as IntersectionObserver));
if (o) o.connected = false;
});
takeRecords = vi.fn(() => []);
constructor(cb: IntersectionObserverCallback) {
observers.push({ cb, self: this as unknown as IntersectionObserver, connected: true });
}
}
vi.stubGlobal('IntersectionObserver', MockIntersectionObserver);
return {
/** Reports every connected observer's target as on screen. */
scrollToEnd() {
for (const o of observers) {
if (!o.connected) continue;
o.cb([{ isIntersecting: true } as IntersectionObserverEntry], o.self);
}
},
/** How many observers are currently watching. */
connectedCount() {
return observers.filter((o) => o.connected).length;
}
};
}
+2
View File
@@ -14,6 +14,7 @@ export function mockInfiniteQuery<T>(opts: {
error?: unknown;
hasNextPage?: boolean;
isFetchingNextPage?: boolean;
isFetchNextPageError?: boolean;
fetchNextPage?: () => void;
refetch?: () => void;
} = {}) {
@@ -24,6 +25,7 @@ export function mockInfiniteQuery<T>(opts: {
error: opts.error,
hasNextPage: opts.hasNextPage ?? false,
isFetchingNextPage: opts.isFetchingNextPage ?? false,
isFetchNextPageError: opts.isFetchNextPageError ?? false,
fetchNextPage: opts.fetchNextPage ?? (() => {}),
refetch: opts.refetch ?? (() => {})
});