GetTracks answered with one JSON object per track: 20.5 MB at 26k tracks, ~350 bytes a row of repeated key names, four cover URLs that are identical across an album, and the album, artist and genre strings repeated on every track. Encoding it was ~170 MB of transient Go allocation, and parsing it the WebView's memory peak. GetTrackTable replaces it: one array per column, every repeated string stored once and sent as an index, genre lists interned, and only the fields the Tracks view reads. LastPlayed and the three larger cover tiers are left to the details dialog, which now fetches whole tracks by path. Each row still goes through trackFromRow, so this is an encoding of the one projection, not a second one. 167 bytes a track against 929 in the test library; a Go test holds it under a quarter of the object encoding, and a Vitest test reads the Go columns and the generated Track interface and fails if they drift. An empty library is now an empty table rather than the "no tracks in library" error the old binding returned. Closes #281
62 lines
2.1 KiB
TypeScript
62 lines
2.1 KiB
TypeScript
/**
|
||
* `perf.M3` and `perf.M4` are both per-row costs paid only while a
|
||
* virtualizer is recycling rows, which makes them invisible to every
|
||
* other tier: nothing renders differently, nothing fails, and the app
|
||
* is merely slower to scroll.
|
||
*
|
||
* The measurement that found them lives in `e2e/perf/measure.mjs` and
|
||
* needs a 50 000-track library. These are the cheap guards that keep
|
||
* the fixes from being undone by someone reading the call site alone,
|
||
* and they assert the *mechanism* rather than a duration — a timing
|
||
* assertion in a component test is a flake, not a regression test.
|
||
*/
|
||
import { describe, expect, it } from 'vitest';
|
||
|
||
import { COLUMN_DEFS } from '@components/track-list/columns';
|
||
import { render } from 'lit';
|
||
|
||
/** Render a column's cell into a detached element and read the HTML. */
|
||
function cell(columnId: string, track: Record<string, unknown>): HTMLElement {
|
||
const host = document.createElement('div');
|
||
|
||
render(COLUMN_DEFS[columnId]?.renderCell?.(track as never), host);
|
||
|
||
return host;
|
||
}
|
||
|
||
describe('the track list Art column', () => {
|
||
const track = {
|
||
CoverArtPath: '/covers/abc.jpg',
|
||
CoverArtSmall: '/covers/abc_sm.jpg',
|
||
CoverArtMedium: '/covers/abc_md.jpg',
|
||
};
|
||
|
||
it('asks for the 100 px tier, not the original, for a 24 px box', () => {
|
||
// The original is commonly 1500×1500 and several hundred kB, and was
|
||
// being decoded in full to draw 576 pixels.
|
||
const img = cell('albumArt', track).querySelector('img');
|
||
|
||
expect(img?.getAttribute('src')).toBe('/covers/abc_sm.jpg');
|
||
});
|
||
|
||
it('keeps the decode off the scroll path', () => {
|
||
const img = cell('albumArt', track).querySelector('img');
|
||
|
||
expect([
|
||
img?.getAttribute('loading'),
|
||
img?.getAttribute('decoding'),
|
||
]).toEqual(['lazy', 'async']);
|
||
});
|
||
|
||
// There is no fallback through the larger tiers any more: the list
|
||
// carries only the small one (#281), and the backend derives every
|
||
// tier from the same file, so it is set whenever any of them is.
|
||
it('renders nothing at all when there is no art', () => {
|
||
const none = cell('albumArt', {
|
||
CoverArtSmall: '',
|
||
});
|
||
|
||
expect(none.querySelector('img')).toBeNull();
|
||
});
|
||
});
|