Skip to content

Fix Library A-Z selector with server-side letter indexing - #1245

Draft
skalthoff wants to merge 1 commit into
mainfrom
fix/library-az-letter-jump
Draft

Fix Library A-Z selector with server-side letter indexing#1245
skalthoff wants to merge 1 commit into
mainfrom
fix/library-az-letter-jump

Conversation

@skalthoff

@skalthoff skalthoff commented Jun 10, 2026

Copy link
Copy Markdown
Collaborator

Problem

A letter tap on the Library A-Z scroller pages through the library sequentially until the selected letter shows up in the loaded sections (onLetterPaginateQuery). Three compounding issues make this unusable at scale:

  • The loop reads a frozen render snapshot. query.hasNextPage and query.data never update inside the loop, so the "found the letter" check can never observe fresh pages — and once the server runs out of pages, fetchNextPage() becomes an instantly-resolving no-op while the frozen hasNextPage stays true: a tight infinite loop that pegs the JS thread.
  • The found-letter check can't match anyway: it compares the lowercased selected letter against uppercase section titles with localeCompare === 0.
  • Even if it worked, it's O(library): jumping to "Z" on a 200k-track library means ~500 sequential 400-item fetches (minutes of crawling, ~100MB+ of JSON) before the freeze.

The scroll that follows also runs against the same stale snapshot, and useTracks has no page window — the crawl accumulates the whole library in memory.

Fix

A letter tap now resolves the exact start index of the letter's section server-side and repositions the infinite query cache directly onto the page containing it. Request budget by list type:

List Direction Requests per jump (cold) Warm
Artists (SortName order) asc + desc 1 count + 1 page (+1 one-time total) 1 count + ≤1 page
Albums sorted by SortName asc + desc 1 count + 1 page (+1 one-time total) 1 count + ≤1 page
Tracks / other letter sorts asc + desc ~10 × 1-item probes + 1 page 0–8 probes, same letter ≈ 0

The count fast path (NameLessThan + Limit=1&EnableTotalRecordCount=true): the server applies NameLessThan to the stored lowercased SortName in both the 10.10 SQLite and 10.11+ EF Core item repositories, so for SortName-ordered lists the count is the boundary index. Descending uses the complement: total − count(< nextLetter). One COUNT query either direction.

The probe search (tracks — Audio SortNames are disc/track-number prefixed, so name filters don't apply): a lower-bound search over startIndex=N&limit=1 probes with three scale optimizations:

  1. Page-granularity termination — the search stops as soon as the bracket fits within one 400-item page and resolves the exact boundary by scanning the page it has to fetch anyway. That alone removes ~9 of the ~18 probes plain bisection would need at 200k.
  2. Interpolation between known letter positions (letters spread roughly evenly through music libraries), alternated with bisection so a pathologically skewed library stays within 2× the bisection bound.
  3. A per-list probe memo — every probe and every loaded window edge is recorded for the session, so each jump tightens the next; re-jumping a letter costs zero probes.

Probes carry ~1KB (limit=1, no images, no user data, enableTotalRecordCount only on the one-time total probe so the server doesn't run a COUNT per probe). Server-side, each probe is an index walk — BaseItems has a composite index ending in SortName on both server generations.

At 200k tracks and ~150ms RTT that's roughly: count path ≈ 0.3–0.5s; cold probe search ≈ 1.5s; warm ≈ 0–1s — versus minutes followed by a frozen app today.

The scroll happens after the repositioned sections render (offset → section/item location), so letters with no items land on the next section and descending sorts work. Downloaded-tracks filter jumps locally with no network.

Also fixed

  • useTracks gets the same 5-page window as the other tabs (bounded memory at 200k) plus backward pagination so the window can scroll both ways after a jump
  • Section-list pagination handlers guarded against duplicate fetches; pull-to-refresh spinner no longer flashes during ordinary pagination
  • Tracks rows no longer do two O(n) indexOf scans per rendered row to build the player queue slice (single-pass flat index map)
  • flattenInfiniteQueryPages appends in place instead of re-copying the section array per item
  • A-Z selector: gestures ignored until layout settles (no ÷0), spinner always clears on failure, haptics only on letter change

Tests

jest/functional/LetterJump.test.ts (32 tests) covers the search math and the request budgets explicitly: cold/warm/same-letter probe counts on synthetic 200k libraries (uniform and 95%-skewed), descending order, missing letters, # edge cases, page-boundary location, section offset mapping, count-path arithmetic (asc + desc), window reuse (no fetch when the page is loaded), and the local downloaded-tracks path.

Full suite: 14 suites / 134 tests passing, tsc and ESLint clean.

Testing this PR

JS-only change — OTA bundles publish automatically for this PR. Worth exercising on a device, ideally against a big library:

  • Cold jump to a far letter on Tracks (should land in ~1–2s, not crawl)
  • Repeat the same jump (should be instant)
  • Jump backward (M → B) on Artists and Albums
  • Descending sort + A-Z on all three tabs
  • Letter with no items (lands on next section)
  • Downloaded-tracks filter + A-Z (no network)
  • Scroll up after a jump (backward pagination) and pull-to-refresh after a jump

@anultravioletaurora

anultravioletaurora commented Jun 10, 2026

Copy link
Copy Markdown
Member

Hold on with this one, #1236 is probably going to conflict this one to high hell when it supersedes

Unless this builds off of that one 🙂

@anultravioletaurora

Copy link
Copy Markdown
Member

@skalthoff conflicts are hot and ready for you :spaghetti

@skalthoff skalthoff closed this Jun 12, 2026
@skalthoff
skalthoff force-pushed the fix/library-az-letter-jump branch from 160551f to 509bdce Compare June 12, 2026 03:32
The A-Z scroller paged through the library sequentially until the
selected letter appeared in the loaded sections. The pagination loop
also read hasNextPage and the section data from a stale render
snapshot, so it always crawled to the end of the library and then
spun forever on the frozen flag, and its found-letter check compared
lowercase letters against uppercase section titles. On large
libraries a single letter tap meant hundreds of sequential page
fetches followed by a frozen JS thread.

Letter taps now resolve the exact start index of the letter's section
server-side and reposition the infinite query cache directly onto the
page containing it:

- SortName-ordered lists (artists; albums sorted by sort name) use a
  single NameLessThan count query. Ascending, the count of items
  before the letter is its start index; descending, the boundary is
  the complement of the count before the next letter. Two requests
  per jump in either direction.
- Everything else (tracks sort by display name; Audio sort names are
  number-prefixed) uses a probe search over startIndex/limit=1
  requests: interpolated between known letter positions, bracketed by
  prior samples, and narrowed only to page granularity - the exact
  boundary is resolved by scanning the page that gets fetched anyway.
  A cold jump on a 200,000 track library costs ~10 one-item probes;
  repeat jumps reuse the accumulated samples and approach zero.
- Probe results are memoized per query key for the session, and the
  loaded window's edges seed the search bracket for free.
- The downloaded-tracks filter jumps locally with no network.

The scroll happens once the repositioned sections render, mapped by
item offset to a section list location, so letters with no items land
on the next section and descending sorts work.

Also in this change:

- Tracks gets the same five-page window as the other tabs (bounded
  memory on huge libraries) plus backward pagination
- Section list pagination handlers are guarded against duplicate
  fetches, and the refresh spinner no longer flashes during ordinary
  infinite-scroll pagination
- Tracks rows no longer scan the whole list twice per render to build
  the player queue slice; a single pass builds a flat index map
- Section building appends in place instead of re-copying the section
  array per item
- A-Z selector ignores gestures until layout settles (no divide by
  zero), always clears its spinner when a jump fails, and only fires
  haptics when the selected letter changes
@skalthoff skalthoff reopened this Jun 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants