Files
yellowjacket/frontend/test/components/lazy-track-details.test.ts
T
yonlu 4a9fe1d207 fix(library): open track details by path, not from the whole library
Track details from the queue, a playlist or a smart playlist looked
the track up in libraryStore's whole-library array, and returned
silently when it had not loaded, so the menu item did nothing. That
dependency is also why every track was fetched eagerly at startup
(20.5 MB at 26k tracks).

GetTracksByPaths answers for the paths in hand, and trackCache holds
the answers: bounded, coalesced into one call per task, and forgetting
what the retag, removal and scan events name. Every opener, and
track-details' own refresh after a save, go through it. A source sweep
pins the whole-library array to the store and the Tracks view.

Closes #279
2026-10-05 23:01:37 -04:00

95 lines
3.9 KiB
TypeScript

/**
* `track-details` is 42 kB and must not ride in the startup chunk.
*
* Five components open it — `track-list`, `cover-grid`, `queue-panel`,
* `playlist-details` and `smart-playlist-details` — and all five used
* to `import` it for side effect, so it was eagerly evaluated before
* first paint however `index.ts` split the routes. Measured: 814.5 kB
* of JS evaluated before first paint, against 772.9 kB after.
*
* What keeps it out is the *absence* of those imports, which is
* invisible: adding one back costs nothing anybody would notice, and
* the dialog carries on working because the chunk is also warmed on
* idle. So the first half of this file reads the five sources and
* fails if one of them reaches for it statically again. A `import
* type` is fine — types are erased and pull in no chunk.
*
* The second half is the reason that is safe: `loadTrackDetails()`
* really does define the element, so an opener that awaits it can then
* use the `<track-details>` its template already rendered. Before the
* chunk lands that element exists but is not upgraded — an inert
* `HTMLElement` with no `show()` — which is the same trap `index.ts`'s
* `VIEW_LOADERS` exists for, and why every opener awaits.
*/
import { describe, expect, it } from 'vitest';
import trackListSource from '@components/track-list/track-list.ts?raw';
import coverGridSource from '@components/cover-grid/cover-grid.ts?raw';
import queuePanelSource from '@components/queue-panel/queue-panel.ts?raw';
import playlistDetailsSource from '@components/playlist-details/playlist-details.ts?raw';
import smartPlaylistDetailsSource from '@components/smart-playlist-details/smart-playlist-details.ts?raw';
import { loadTrackDetails } from '@utils/lazy-track-details';
const OPENERS: Array<[string, string]> = [
['track-list', trackListSource],
['cover-grid', coverGridSource],
['queue-panel', queuePanelSource],
['playlist-details', playlistDetailsSource],
['smart-playlist-details', smartPlaylistDetailsSource],
];
/** A side-effect import: `import '…track-details…'`, no bindings. */
const SIDE_EFFECT_IMPORT =
/^\s*import\s+['"][^'"]*track-details[^'"]*['"]\s*;?\s*$/m;
describe('track-details stays out of the startup chunk', () => {
it.each(OPENERS)(
'%s does not import track-details for side effect',
(_name, source) => {
expect(SIDE_EFFECT_IMPORT.test(source)).toBe(false);
},
);
// Directly, or through `utils/track-details-opener`, which awaits
// it and is what the path-keyed openers share (#279).
it.each(OPENERS)('%s loads it at the point of use', (_name, source) => {
expect(source).toMatch(
/loadTrackDetails|showTrackDetailsForPath|showBatchTrackDetailsForPaths/,
);
});
});
describe('loadTrackDetails', () => {
it('defines the element and reports success', async () => {
await expect(loadTrackDetails()).resolves.toBe(true);
expect(customElements.get('track-details')).toBeDefined();
});
it('leaves a rendered element usable', async () => {
// The invariant every opener depends on: after the await, the
// `<track-details>` its template already rendered has a
// `show()` on it. (This cannot observe the *un*-upgraded state
// — the suite above has already defined the element, and a
// custom element cannot be undefined again — so it asserts the
// postcondition rather than the transition.)
const el = document.createElement('track-details');
document.body.appendChild(el);
try {
await loadTrackDetails();
expect(typeof (el as { show?: unknown }).show).toBe('function');
} finally {
el.remove();
}
});
it('is memoised, so a second open does not refetch', async () => {
const first = loadTrackDetails();
expect(loadTrackDetails()).toBe(first);
await first;
});
});