perf(library): load a collection when a view needs it, not at startup
All four collections were fetched at DOMContentLoaded and refetched on every invalidation, whichever view was showing. On a 26 138-track library the track list was 20.5 MB of that, and encoding it cost the backend ~170 MB of transient allocation — paid by someone looking at Home, which draws none of it. - The store warms only albums, artists and genres, on idle after first paint: 1.6 MB together, and what made those views instant. - A nav item prefetches on hover and on keyboard focus, which is the ~100 ms before the click that a cold open would otherwise wait. - An invalidation refetches what something had loaded, and nothing else — a scan no longer loads the track list of a library whose Tracks view nobody has opened. - `index.html`'s first-paint `<track-list>` is `view-hidden`, and `index.ts` no longer activates it: it is markup, not a decision about which view the launch lands on, and activating it was what fetched the whole list for a landing on Home. The track list itself loads on view activation, which the shell drives. Measured on 50 000 tracks: backend RSS at rest 543 → 296 MB, peak 571 → 296 MB, Go heap held 361 → 125 MB, JS heap 31.8 → 18 MB, binding bytes at rest 35.9 → 12.1 MB, heap after a browse 36.5 → 22.7 MB. Tracks first open 26 ms; slowest view open 57 ms. Closes #280
This commit is contained in:
1 parent
3d9828b847
commit
620151aa41
11 files changed
+538
-73
No files matched your search
+162
-5
@@ -42,6 +42,13 @@
|
|||||||
* browse number below could not see this, because it
|
* browse number below could not see this, because it
|
||||||
* visits Explore without ever typing in it.
|
* visits Explore without ever typing in it.
|
||||||
* heap JS heap after a scripted browse, post-GC. m3.
|
* heap JS heap after a scripted browse, post-GC. m3.
|
||||||
|
* memory what the app holds at rest after launch, before any
|
||||||
|
* view but the landing one is opened: the backend
|
||||||
|
* process's RSS and peak, its Go heap, the page's JS
|
||||||
|
* heap, and the bytes each binding returned on the way.
|
||||||
|
* Then the same after the first open of Tracks, and how
|
||||||
|
* long that open took to its first *row* — the number
|
||||||
|
* eager loading was buying (#280/#281).
|
||||||
*
|
*
|
||||||
* Usage:
|
* Usage:
|
||||||
* node e2e/perf/measure.mjs --label before
|
* node e2e/perf/measure.mjs --label before
|
||||||
@@ -51,7 +58,7 @@
|
|||||||
* Requires a running app: `make dev-headless SEED=bulk`.
|
* Requires a running app: `make dev-headless SEED=bulk`.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync } from 'node:fs';
|
||||||
import { dirname, resolve } from 'node:path';
|
import { dirname, resolve } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { chromium } from '@playwright/test';
|
import { chromium } from '@playwright/test';
|
||||||
@@ -190,6 +197,135 @@ const SEARCH_DEBOUNCE_MS = 150;
|
|||||||
|
|
||||||
/* -------------------------------------------------------------------- */
|
/* -------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The backend process's memory, from /proc and its own pprof endpoint.
|
||||||
|
*
|
||||||
|
* Linux and a dev build only (pprof is mounted by `-tags dev`); either
|
||||||
|
* missing is a null, not a failure — this is a measurement harness, and
|
||||||
|
* a number it cannot take is reported as absent rather than as zero.
|
||||||
|
*/
|
||||||
|
async function backendMemory() {
|
||||||
|
const out = { rssMB: null, peakMB: null, anonMB: null, goHeapInuseMB: null, goHeapHeldMB: null };
|
||||||
|
const pid = findBackendPid();
|
||||||
|
|
||||||
|
if (pid) {
|
||||||
|
try {
|
||||||
|
const status = readFileSync(`/proc/${pid}/status`, 'utf8');
|
||||||
|
const kb = (k) => Number(status.match(new RegExp(`^${k}:\\s+(\\d+)`, 'm'))?.[1] ?? NaN);
|
||||||
|
|
||||||
|
out.rssMB = round(kb('VmRSS') / 1024, 1);
|
||||||
|
out.peakMB = round(kb('VmHWM') / 1024, 1);
|
||||||
|
out.anonMB = round(kb('RssAnon') / 1024, 1);
|
||||||
|
} catch {
|
||||||
|
// The process went away between finding it and reading it.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const text = await (await fetch('http://localhost:6060/debug/pprof/heap?debug=1')).text();
|
||||||
|
const field = (k) => Number(text.match(new RegExp(`^# ${k} = (\\d+)`, 'm'))?.[1] ?? NaN);
|
||||||
|
|
||||||
|
out.goHeapInuseMB = round(field('HeapInuse') / 1048576, 1);
|
||||||
|
// What the runtime holds from the OS: the RSS the heap accounts for.
|
||||||
|
out.goHeapHeldMB = round((field('HeapSys') - field('HeapReleased')) / 1048576, 1);
|
||||||
|
} catch {
|
||||||
|
// Not a dev build, or pprof is not listening.
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The pid of the running `yj-dev` binary, found by name under /proc. */
|
||||||
|
function findBackendPid() {
|
||||||
|
try {
|
||||||
|
for (const entry of readdirSync('/proc')) {
|
||||||
|
if (!/^\d+$/.test(entry)) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (readFileSync(`/proc/${entry}/comm`, 'utf8').trim() === 'yj-dev') return entry;
|
||||||
|
} catch {
|
||||||
|
// Raced with an exiting process.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// No /proc: not Linux.
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bytes returned per binding name, largest first. */
|
||||||
|
function bytesByBinding(calls) {
|
||||||
|
const by = {};
|
||||||
|
|
||||||
|
for (const c of calls) by[c.path] = (by[c.path] ?? 0) + (c.bytes ?? 0);
|
||||||
|
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(by)
|
||||||
|
.filter(([, b]) => b > 0)
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.map(([k, b]) => [k, round(b / 1048576, 2)]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pageMemory(page, client) {
|
||||||
|
await client.send('HeapProfiler.collectGarbage');
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
|
const heap = await client.send('Runtime.getHeapUsage');
|
||||||
|
const dom = await client.send('Memory.getDOMCounters');
|
||||||
|
|
||||||
|
return { jsHeapMB: round(heap.usedSize / 1048576, 1), domNodes: dom.nodes };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function measureMemory(page, client) {
|
||||||
|
// Settled: the landing view has loaded and any idle warming has run.
|
||||||
|
await page.waitForTimeout(5000);
|
||||||
|
|
||||||
|
const startupCalls = await page.evaluate(() => window.__yjPerf.calls);
|
||||||
|
const atRest = {
|
||||||
|
backend: await backendMemory(),
|
||||||
|
page: await pageMemory(page, client),
|
||||||
|
bytesByBindingMB: bytesByBinding(startupCalls),
|
||||||
|
totalBindingMB: round(startupCalls.reduce((n, c) => n + (c.bytes ?? 0), 0) / 1048576, 2),
|
||||||
|
};
|
||||||
|
|
||||||
|
// First open of Tracks, to its first row: what a user waits for when
|
||||||
|
// nothing was loaded ahead of them.
|
||||||
|
const since = await page.evaluate(() => performance.now());
|
||||||
|
const firstRowMs = await page.evaluate(async () => {
|
||||||
|
const t0 = performance.now();
|
||||||
|
|
||||||
|
document.dispatchEvent(new CustomEvent('navigate', { detail: { view: 'tracks' } }));
|
||||||
|
|
||||||
|
for (;;) {
|
||||||
|
const list = document.querySelector('#main-content > track-list:not(.view-hidden)');
|
||||||
|
|
||||||
|
if (list?.shadowRoot?.querySelector('[data-testid="track-row"]')) {
|
||||||
|
return Math.round(performance.now() - t0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (performance.now() - t0 > 60000) return null;
|
||||||
|
await new Promise((r) => requestAnimationFrame(r));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
const tracksCalls = await page.evaluate((t) => window.__yjPerf.since(t), since);
|
||||||
|
|
||||||
|
return {
|
||||||
|
atRest,
|
||||||
|
tracksOpen: {
|
||||||
|
firstRowMs,
|
||||||
|
bytesByBindingMB: bytesByBinding(tracksCalls),
|
||||||
|
backend: await backendMemory(),
|
||||||
|
page: await pageMemory(page, client),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------- */
|
||||||
|
|
||||||
async function measureStartup(page) {
|
async function measureStartup(page) {
|
||||||
const nav = await page.evaluate(() => {
|
const nav = await page.evaluate(() => {
|
||||||
const n = performance.getEntriesByType('navigation')[0];
|
const n = performance.getEntriesByType('navigation')[0];
|
||||||
@@ -234,16 +370,23 @@ async function measureStartup(page) {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// "First row on screen" is the number a user experiences as startup;
|
// "First row on screen" is the number a user experiences as startup
|
||||||
// FCP fires on the chrome around an empty list.
|
// *when the app lands on Tracks*; FCP fires on the chrome around an
|
||||||
|
// empty list.
|
||||||
|
//
|
||||||
|
// The deadline is short because since #280 a landing on any other
|
||||||
|
// view leaves the track list unloaded, and this would otherwise
|
||||||
|
// spend a minute waiting for a row that is not coming. The number
|
||||||
|
// that means something either way is `memory.tracksOpen.firstRowMs`,
|
||||||
|
// which opens Tracks and waits for its first row deliberately.
|
||||||
const firstRowMs = await page.evaluate(async () => {
|
const firstRowMs = await page.evaluate(async () => {
|
||||||
const t0 = performance.now();
|
const t0 = performance.now();
|
||||||
const deadline = t0 + 60000;
|
const deadline = t0 + 2000;
|
||||||
|
|
||||||
for (;;) {
|
for (;;) {
|
||||||
const list = document.querySelector('track-list');
|
const list = document.querySelector('track-list');
|
||||||
const row = list?.shadowRoot?.querySelector('[role="row"], .track-row');
|
const row = list?.shadowRoot?.querySelector('[role="row"], .track-row');
|
||||||
if (row) return Math.round(performance.now() - t0 + (performance.timeOrigin ? 0 : 0));
|
if (row) return Math.round(performance.now() - t0);
|
||||||
if (performance.now() > deadline) return null;
|
if (performance.now() > deadline) return null;
|
||||||
await new Promise((r) => setTimeout(r, 16));
|
await new Promise((r) => setTimeout(r, 16));
|
||||||
}
|
}
|
||||||
@@ -1845,6 +1988,12 @@ async function run(label) {
|
|||||||
loadWallMs: Date.now() - t0,
|
loadWallMs: Date.now() - t0,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// First of all: at rest means before any measurement opens a view.
|
||||||
|
console.log(' memory at rest, then Tracks first open…');
|
||||||
|
report.memory = await measureMemory(page, client);
|
||||||
|
await page.evaluate(() => document.dispatchEvent(
|
||||||
|
new CustomEvent('navigate', { detail: { view: 'home' } }),
|
||||||
|
));
|
||||||
console.log(' startup…');
|
console.log(' startup…');
|
||||||
report.startup = await measureStartup(page);
|
report.startup = await measureStartup(page);
|
||||||
// Before anything else navigates: every view's first open has to be
|
// Before anything else navigates: every view's first open has to be
|
||||||
@@ -1907,6 +2056,14 @@ async function run(label) {
|
|||||||
|
|
||||||
const ROWS = [
|
const ROWS = [
|
||||||
['First contentful paint', (r) => fmt(r.startup.firstContentfulPaintMs, 'ms')],
|
['First contentful paint', (r) => fmt(r.startup.firstContentfulPaintMs, 'ms')],
|
||||||
|
['At rest: backend RSS', (r) => fmt(r.memory?.atRest.backend.rssMB, 'MB')],
|
||||||
|
['At rest: backend peak RSS', (r) => fmt(r.memory?.atRest.backend.peakMB, 'MB')],
|
||||||
|
['At rest: Go heap held', (r) => fmt(r.memory?.atRest.backend.goHeapHeldMB, 'MB')],
|
||||||
|
['At rest: JS heap', (r) => fmt(r.memory?.atRest.page.jsHeapMB, 'MB')],
|
||||||
|
['At rest: binding bytes', (r) => fmt(r.memory?.atRest.totalBindingMB, 'MB')],
|
||||||
|
['Tracks first open: first row', (r) => fmt(r.memory?.tracksOpen.firstRowMs, 'ms')],
|
||||||
|
['Tracks open: backend peak RSS', (r) => fmt(r.memory?.tracksOpen.backend.peakMB, 'MB')],
|
||||||
|
['Tracks open: JS heap', (r) => fmt(r.memory?.tracksOpen.page.jsHeapMB, 'MB')],
|
||||||
['First track row', (r) => fmt(r.startup.firstRowAfterLoadMs, 'ms')],
|
['First track row', (r) => fmt(r.startup.firstRowAfterLoadMs, 'ms')],
|
||||||
['JS transferred', (r) => fmt(round(r.startup.scriptBytes / 1024), 'kB')],
|
['JS transferred', (r) => fmt(round(r.startup.scriptBytes / 1024), 'kB')],
|
||||||
['JS evaluated before first paint', (r) => fmt(round((r.startup.scriptBytesBeforePaint ?? 0) / 1024), 'kB')],
|
['JS evaluated before first paint', (r) => fmt(round((r.startup.scriptBytesBeforePaint ?? 0) / 1024), 'kB')],
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* #280: launching the app must not load the track list.
|
||||||
|
*
|
||||||
|
* Every collection used to be fetched at `DOMContentLoaded` and
|
||||||
|
* refetched on every invalidation, whichever view was showing. On a
|
||||||
|
* 26 138-track library the track list was 20.5 MB of that, and encoding
|
||||||
|
* it cost the backend ~170 MB of transient allocation — for someone
|
||||||
|
* looking at Home, which draws none of it. Measured on 50 000 tracks:
|
||||||
|
* 543 MB of backend RSS at rest before, 296 MB after.
|
||||||
|
*
|
||||||
|
* The negative assertion is the point, and it needs its complement: "no
|
||||||
|
* `GetTrackTable`" also holds on a build that fetches nothing at all,
|
||||||
|
* so the same spec opens Tracks and watches it arrive.
|
||||||
|
*/
|
||||||
|
import { test, expect, bindingCalls, navigateTo } from '../support/fixtures.js';
|
||||||
|
|
||||||
|
const TRACKS = 'library.Library.GetTrackTable';
|
||||||
|
|
||||||
|
/** The seed's default page, which is what a launch lands on. */
|
||||||
|
const LANDING_VIEW = 'home';
|
||||||
|
|
||||||
|
test('launching on Home does not load the track list', async ({ app }) => {
|
||||||
|
// Past the store's idle warm-up: "nothing was fetched" has to mean
|
||||||
|
// nothing, not "nothing has happened yet".
|
||||||
|
await app.waitForTimeout(3_500);
|
||||||
|
|
||||||
|
const calls = await bindingCalls(app);
|
||||||
|
|
||||||
|
expect(calls, 'the warm-up for the small collections ran').toContain(
|
||||||
|
'library.Library.GetAlbums',
|
||||||
|
);
|
||||||
|
expect(calls, `the track list was fetched at launch (${LANDING_VIEW})`).not.toContain(
|
||||||
|
TRACKS,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('opening Tracks loads it, and only then', async ({ app }) => {
|
||||||
|
await app.waitForTimeout(1_000);
|
||||||
|
expect(await bindingCalls(app)).not.toContain(TRACKS);
|
||||||
|
|
||||||
|
await navigateTo(app, 'tracks');
|
||||||
|
await expect(app.getByTestId('track-row').first()).toBeVisible();
|
||||||
|
|
||||||
|
expect(await bindingCalls(app)).toContain(TRACKS);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a hover on the nav item starts the load before the click', async ({ app }) => {
|
||||||
|
await app.waitForTimeout(1_000);
|
||||||
|
|
||||||
|
const nav = app.getByTestId('nav-tracks');
|
||||||
|
|
||||||
|
// The pointer entering the item is the whole mechanism; nothing is
|
||||||
|
// clicked, so the data must arrive because of the hover alone.
|
||||||
|
await nav.hover();
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await bindingCalls(app)).includes(TRACKS))
|
||||||
|
.toBe(true);
|
||||||
|
});
|
||||||
+9
-1
@@ -51,7 +51,15 @@
|
|||||||
<div class="content-area">
|
<div class="content-area">
|
||||||
<main class="main-panel" data-active-view="tracks" data-testid="main-content" id="main-content"
|
<main class="main-panel" data-active-view="tracks" data-testid="main-content" id="main-content"
|
||||||
tabindex="-1">
|
tabindex="-1">
|
||||||
<track-list></track-list>
|
<!-- The first-paint content of the main panel. It is
|
||||||
|
`view-hidden` because whether Tracks is the view to
|
||||||
|
show is not known until `GetDefaultPage` answers, and a
|
||||||
|
seed that is visible is a seed that is *active* -- which
|
||||||
|
is what made the whole track list load at launch for a
|
||||||
|
landing on Home (#280). The first navigation decides:
|
||||||
|
it unhides and activates this element if the landing
|
||||||
|
view is Tracks, and otherwise replaces it. -->
|
||||||
|
<track-list class="view-hidden"></track-list>
|
||||||
</main>
|
</main>
|
||||||
<queue-panel id="queue-panel"></queue-panel>
|
<queue-panel id="queue-panel"></queue-panel>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+15
-2
@@ -200,13 +200,18 @@ const mainContent = document.getElementById('main-content');
|
|||||||
// tracked as currentViewEl, is never hidden: two visible primary views
|
// tracked as currentViewEl, is never hidden: two visible primary views
|
||||||
// splitting the main panel between them regardless of which is
|
// splitting the main panel between them regardless of which is
|
||||||
// selected.
|
// selected.
|
||||||
|
//
|
||||||
|
// It is deliberately not activated here. It is markup, not a decision:
|
||||||
|
// the shell does not yet know which view the launch lands on, and
|
||||||
|
// activating it starts the Tracks view's work — its data fetch, above
|
||||||
|
// all (#280) — for a launch that is about to land on Home. The first
|
||||||
|
// navigation is what activates whichever view it lands on.
|
||||||
if (mainContent) {
|
if (mainContent) {
|
||||||
const initialTrackList = mainContent.querySelector('track-list');
|
const initialTrackList = mainContent.querySelector('track-list');
|
||||||
|
|
||||||
if (initialTrackList) {
|
if (initialTrackList) {
|
||||||
viewCache.set('tracks', initialTrackList as HTMLElement);
|
viewCache.set('tracks', initialTrackList as HTMLElement);
|
||||||
currentViewEl = initialTrackList as HTMLElement;
|
currentViewEl = initialTrackList as HTMLElement;
|
||||||
activateView(currentViewEl);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -580,10 +585,14 @@ async function handleNavigate(
|
|||||||
}
|
}
|
||||||
default: {
|
default: {
|
||||||
const fallback = document.createElement('div');
|
const fallback = document.createElement('div');
|
||||||
|
const message = document.createElement('p');
|
||||||
|
|
||||||
fallback.style.padding = '1em';
|
fallback.style.padding = '1em';
|
||||||
fallback.style.color = 'var(--yj-text-secondary, #b3b3b3)';
|
fallback.style.color = 'var(--yj-text-secondary, #b3b3b3)';
|
||||||
fallback.innerHTML = `<p>Coming soon: ${view}</p>`;
|
// textContent, not innerHTML: `view` comes from a navigation
|
||||||
|
// detail, which is app-supplied but not app-owned.
|
||||||
|
message.textContent = `Coming soon: ${view}`;
|
||||||
|
fallback.append(message);
|
||||||
mainContent.appendChild(fallback);
|
mainContent.appendChild(fallback);
|
||||||
currentDetailEl = fallback;
|
currentDetailEl = fallback;
|
||||||
}
|
}
|
||||||
@@ -620,6 +629,10 @@ function warmViewChunks(): void {
|
|||||||
|
|
||||||
/** requestIdleCallback where it exists; WebKit2GTK does not have it. */
|
/** requestIdleCallback where it exists; WebKit2GTK does not have it. */
|
||||||
function schedule(fn: () => void): void {
|
function schedule(fn: () => void): void {
|
||||||
|
// SAFETY: `requestIdleCallback` is not in this project's DOM lib
|
||||||
|
// types, so it is read through an optional-property shape; the
|
||||||
|
// property is either absent (undefined) or the browser's own
|
||||||
|
// function, which is what the guard below checks.
|
||||||
const ric = (
|
const ric = (
|
||||||
window as unknown as {
|
window as unknown as {
|
||||||
requestIdleCallback?: (cb: () => void) => number;
|
requestIdleCallback?: (cb: () => void) => number;
|
||||||
|
|||||||
@@ -1553,9 +1553,13 @@ export class QueuePanel
|
|||||||
indices: number[],
|
indices: number[],
|
||||||
) {
|
) {
|
||||||
const queueTracks = this.queue.tracks;
|
const queueTracks = this.queue.tracks;
|
||||||
const filePaths = indices
|
const filePaths: string[] = [];
|
||||||
.map((i) => queueTracks[i]?.filePath)
|
|
||||||
.filter((fp): fp is string => fp != null);
|
for (const i of indices) {
|
||||||
|
const path = queueTracks[i]?.filePath;
|
||||||
|
|
||||||
|
if (path != null) filePaths.push(path);
|
||||||
|
}
|
||||||
|
|
||||||
await showBatchTrackDetailsForPaths(
|
await showBatchTrackDetailsForPaths(
|
||||||
() => this.trackDetailsDialog,
|
() => this.trackDetailsDialog,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { designTokens } from '../../styles/tokens.css';
|
|||||||
import type { DragActiveDetail } from '@utils/drag-controller';
|
import type { DragActiveDetail } from '@utils/drag-controller';
|
||||||
import { ActiveViewController } from '@store/controllers/active-view-controller';
|
import { ActiveViewController } from '@store/controllers/active-view-controller';
|
||||||
import { ViewVisibilityController } from '@store/controllers/view-visibility-controller';
|
import { ViewVisibilityController } from '@store/controllers/view-visibility-controller';
|
||||||
|
import { libraryStore } from '@store/library-store';
|
||||||
import { VIEW_META } from '../../services/view-meta';
|
import { VIEW_META } from '../../services/view-meta';
|
||||||
import type { View } from '../../services/view-meta';
|
import type { View } from '../../services/view-meta';
|
||||||
|
|
||||||
@@ -352,6 +353,10 @@ export class AppSidebar extends LitElement {
|
|||||||
: 'false'}
|
: 'false'}
|
||||||
@click=${() =>
|
@click=${() =>
|
||||||
this.navigate(item.id)}
|
this.navigate(item.id)}
|
||||||
|
@mouseenter=${() =>
|
||||||
|
this.prefetch(item.id)}
|
||||||
|
@focus=${() =>
|
||||||
|
this.prefetch(item.id)}
|
||||||
@dragover=${(e: DragEvent) =>
|
@dragover=${(e: DragEvent) =>
|
||||||
this.onNavDragOver(
|
this.onNavDragOver(
|
||||||
e,
|
e,
|
||||||
@@ -503,6 +508,19 @@ export class AppSidebar extends LitElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start loading what this view draws, on hover or keyboard focus.
|
||||||
|
*
|
||||||
|
* The ~100 ms before the click is the whole point: #280 stopped
|
||||||
|
* fetching every collection at startup, and this is what keeps the
|
||||||
|
* view that *is* opened from paying the whole payload after the
|
||||||
|
* click. Nothing is awaited and nothing is reported here — the view
|
||||||
|
* itself reports a failure, and this is the same request.
|
||||||
|
*/
|
||||||
|
private prefetch(view: View) {
|
||||||
|
libraryStore.prefetch(view);
|
||||||
|
}
|
||||||
|
|
||||||
private navigate(view: View) {
|
private navigate(view: View) {
|
||||||
// No optimistic highlight: the shell answers, and it answers
|
// No optimistic highlight: the shell answers, and it answers
|
||||||
// synchronously in `handleNavigate` before it awaits anything.
|
// synchronously in `handleNavigate` before it awaits anything.
|
||||||
|
|||||||
@@ -1336,11 +1336,6 @@ export class TrackList
|
|||||||
super.connectedCallback();
|
super.connectedCallback();
|
||||||
this.restoreSortPreferences();
|
this.restoreSortPreferences();
|
||||||
|
|
||||||
if (this.externalTracks) {
|
|
||||||
this.tracks = this.externalTracks;
|
|
||||||
} else {
|
|
||||||
this.loadTracks();
|
|
||||||
}
|
|
||||||
this.resizeObserver = new ResizeObserver(
|
this.resizeObserver = new ResizeObserver(
|
||||||
() => {
|
() => {
|
||||||
this.onHostResize();
|
this.onHostResize();
|
||||||
@@ -1390,10 +1385,33 @@ export class TrackList
|
|||||||
this.resizeObserver = null;
|
this.resizeObserver = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the list when this becomes the view on screen (#280).
|
||||||
|
*
|
||||||
|
* Not on connection: `index.html` renders a `<track-list>` as the
|
||||||
|
* main panel's first-paint content, so a connection-time fetch was
|
||||||
|
* the whole library loaded at launch for a landing on Home — 12 MB
|
||||||
|
* at 50 000 tracks, and the backend's peak RSS with it. The shell
|
||||||
|
* activates this element only when a navigation lands on Tracks.
|
||||||
|
*
|
||||||
|
* Called on *every* activation, not just the first: the store may
|
||||||
|
* have refetched while this view was off screen, and `getTracks()`
|
||||||
|
* answers from its cache when nothing changed.
|
||||||
|
*/
|
||||||
|
protected override onViewActivate(): void {
|
||||||
|
if (this.externalTracks) {
|
||||||
|
this.tracks = this.externalTracks;
|
||||||
|
} else {
|
||||||
|
void this.loadTracks();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.attachListListeners();
|
||||||
|
}
|
||||||
|
|
||||||
/** Document-level listeners belong to the *visible* list. A cached
|
/** Document-level listeners belong to the *visible* list. A cached
|
||||||
* list is never disconnected, so this is the only place they can be
|
* list is never disconnected, so this is the only place they can be
|
||||||
* taken down again. */
|
* taken down again. */
|
||||||
protected override onViewActivate(): void {
|
private attachListListeners(): void {
|
||||||
this.listenWhileActive(document, 'click', this.clearSelectionHandler);
|
this.listenWhileActive(document, 'click', this.clearSelectionHandler);
|
||||||
this.listenWhileActive(
|
this.listenWhileActive(
|
||||||
document,
|
document,
|
||||||
@@ -1560,9 +1578,10 @@ export class TrackList
|
|||||||
this.selection.clear();
|
this.selection.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-fetch when the store delivers fresh
|
// Re-fetch when the store delivers fresh data after an
|
||||||
// data after eager refetch on invalidation.
|
// invalidation. Off screen the list does nothing with it, and
|
||||||
if (!this.externalTracks) {
|
// the next activation re-reads the store (#280).
|
||||||
|
if (!this.externalTracks && this.viewActive) {
|
||||||
const cached =
|
const cached =
|
||||||
this.libraryCtrl.cachedTracks;
|
this.libraryCtrl.cachedTracks;
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,14 @@ const COVER_SIZE_DEFAULT = 176;
|
|||||||
/** localStorage key for persisted cover size. */
|
/** localStorage key for persisted cover size. */
|
||||||
const COVER_SIZE_KEY = 'cover-grid-size';
|
const COVER_SIZE_KEY = 'cover-grid-size';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long the small-collection warm-up will wait for idle before
|
||||||
|
* running anyway. It is speculative, but a busy main thread must not
|
||||||
|
* mean Albums is slow to open — the timeout is the promise that it is
|
||||||
|
* only ever deferred, never skipped.
|
||||||
|
*/
|
||||||
|
const WARM_IDLE_TIMEOUT_MS = 3_000;
|
||||||
|
|
||||||
class LibraryStore {
|
class LibraryStore {
|
||||||
private tracks: ListTrack[] | null = null;
|
private tracks: ListTrack[] | null = null;
|
||||||
private albums: library.Album[] | null = null;
|
private albums: library.Album[] | null = null;
|
||||||
@@ -110,34 +118,82 @@ class LibraryStore {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.loadCoverSize();
|
this.loadCoverSize();
|
||||||
this.deferEagerFetch();
|
this.warmSmallCollectionsOnIdle();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Schedules eagerFetch() to run after the DOM is ready.
|
* Warm the three small collections once the app is idle after first
|
||||||
* The LibraryStore singleton is instantiated during ES module
|
* paint, and deliberately not the tracks (#280).
|
||||||
* evaluation (import time), so calling eagerFetch() in the
|
*
|
||||||
* constructor would fire 4 backend roundtrips before the app
|
* All four used to be fetched together at `DOMContentLoaded`,
|
||||||
* shell has rendered. Deferring to the 'DOMContentLoaded'
|
* whichever view was showing. On a 26 138-track library the track
|
||||||
* event (or calling immediately if the DOM is already parsed)
|
* list was 20.5 MB of that, and encoding it cost the backend ~170 MB
|
||||||
* lets the shell paint first, then begins data loading.
|
* of transient allocation — paid by someone looking at Home, which
|
||||||
|
* needs none of it. Measured on 50 000 tracks: 543 MB of backend RSS
|
||||||
|
* at rest before, 296 MB after, and 12.1 MB of binding bytes instead
|
||||||
|
* of 35.9.
|
||||||
|
*
|
||||||
|
* Albums, artists and genres are 1.6 MB together, so they are still
|
||||||
|
* fetched ahead of the click — that is what made those views
|
||||||
|
* instant. Tracks are 12 MB at 50 000 and are fetched by the view
|
||||||
|
* that draws them, or by `prefetch` from a hover.
|
||||||
|
*
|
||||||
|
* Idle rather than immediate: this is speculative, so it must not
|
||||||
|
* compete with the first paint.
|
||||||
*/
|
*/
|
||||||
private deferEagerFetch(): void {
|
private warmSmallCollectionsOnIdle(): void {
|
||||||
if (document.readyState === 'loading') {
|
const warm = () => {
|
||||||
window.addEventListener(
|
const logged = this.failureReporter();
|
||||||
'DOMContentLoaded',
|
|
||||||
() => {
|
void this.getAlbums().catch(logged('albums'));
|
||||||
this.eagerFetch();
|
void this.getArtists().catch(logged('artists'));
|
||||||
},
|
void this.getGenres().catch(logged('genres'));
|
||||||
{ once: true },
|
};
|
||||||
);
|
|
||||||
|
if (typeof requestIdleCallback === 'function') {
|
||||||
|
requestIdleCallback(warm, { timeout: WARM_IDLE_TIMEOUT_MS });
|
||||||
} else {
|
} else {
|
||||||
// DOM already parsed (shouldn't happen during module
|
setTimeout(warm, 0);
|
||||||
// eval, but handles dynamic instantiation safely).
|
|
||||||
this.eagerFetch();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start loading what a view will need, without waiting for it.
|
||||||
|
*
|
||||||
|
* A nav item calls this on hover or focus: that is the ~100 ms
|
||||||
|
* before the click, and it is what #280 trades for not paying for
|
||||||
|
* every collection at startup whether or not anyone goes there.
|
||||||
|
*
|
||||||
|
* A view this store holds nothing for is ignored rather than an
|
||||||
|
* error — it is a hint, and a hint about Home is not a mistake.
|
||||||
|
* The failure is swallowed here because the view that wanted the
|
||||||
|
* data reports it: this is the same request, already deduplicated
|
||||||
|
* by `inFlight`, and it has no caller to reject to.
|
||||||
|
*/
|
||||||
|
prefetch(view: string): void {
|
||||||
|
const started = (() => {
|
||||||
|
switch (view) {
|
||||||
|
case 'tracks':
|
||||||
|
return this.getTracks();
|
||||||
|
case 'albums':
|
||||||
|
return this.getAlbums();
|
||||||
|
case 'artists':
|
||||||
|
return this.getArtists();
|
||||||
|
case 'genres':
|
||||||
|
return this.getGenres();
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
void started?.catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
private failureReporter(): (what: string) => (err: unknown) => void {
|
||||||
|
return (what) => (err) =>
|
||||||
|
console.error(`library: could not load ${what}`, err);
|
||||||
|
}
|
||||||
|
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
// DATA ACCESS
|
// DATA ACCESS
|
||||||
// Returns cached data or fetches from backend on first access.
|
// Returns cached data or fetches from backend on first access.
|
||||||
@@ -595,6 +651,8 @@ class LibraryStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const loaded = this.loadedCollections();
|
||||||
|
|
||||||
this.albums = null;
|
this.albums = null;
|
||||||
this.artists = null;
|
this.artists = null;
|
||||||
this.genres = null;
|
this.genres = null;
|
||||||
@@ -610,50 +668,63 @@ class LibraryStore {
|
|||||||
this.changeGen++;
|
this.changeGen++;
|
||||||
this.notify();
|
this.notify();
|
||||||
|
|
||||||
const logged = (what: string) => (err: unknown) =>
|
// Only the summaries something was showing (#280): a removal
|
||||||
console.error(`library: could not reload ${what}`, err);
|
// nobody was looking at does not load a collection to correct it.
|
||||||
|
this.refetchLoaded({
|
||||||
void this.getAlbums().catch(logged('albums'));
|
albums: loaded.albums,
|
||||||
void this.getArtists().catch(logged('artists'));
|
artists: loaded.artists,
|
||||||
void this.getGenres().catch(logged('genres'));
|
genres: loaded.genres,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private invalidate(): void {
|
private invalidate(): void {
|
||||||
|
// Which collections something has actually loaded, before they
|
||||||
|
// are dropped. Refetching all four here would undo #280: a scan
|
||||||
|
// finishing would fetch the track list of a library nobody has
|
||||||
|
// opened the Tracks view on.
|
||||||
|
const loaded = this.loadedCollections();
|
||||||
|
|
||||||
this.tracks = null;
|
this.tracks = null;
|
||||||
this.albums = null;
|
this.albums = null;
|
||||||
this.artists = null;
|
this.artists = null;
|
||||||
this.genres = null;
|
this.genres = null;
|
||||||
// Anything still in flight was asked for on behalf of a
|
// Anything still in flight was asked for on behalf of a
|
||||||
// selection that no longer applies: forget it, so the eager
|
// selection that no longer applies: forget it, so the refetch
|
||||||
// refetch below starts a request for the current one rather
|
// below starts a request for the current one rather than
|
||||||
// than adopting the old one's answer.
|
// adopting the old one's answer.
|
||||||
this.inFlight.clear();
|
this.inFlight.clear();
|
||||||
this.cacheGen++;
|
this.cacheGen++;
|
||||||
this.changeGen++;
|
this.changeGen++;
|
||||||
this.scrollPositions = { tracks: 0, albums: 0, artists: 0, genres: 0 };
|
this.scrollPositions = { tracks: 0, albums: 0, artists: 0, genres: 0 };
|
||||||
this.notify();
|
this.notify();
|
||||||
this.eagerFetch();
|
this.refetchLoaded(loaded);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The collections currently held, keyed by the view that draws them. */
|
||||||
|
private loadedCollections(): Partial<Record<ViewName, boolean>> {
|
||||||
|
return {
|
||||||
|
tracks: this.tracks !== null,
|
||||||
|
albums: this.albums !== null,
|
||||||
|
artists: this.artists !== null,
|
||||||
|
genres: this.genres !== null,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches all library data. Called after DOM ready
|
* Refetch exactly the collections named, and nothing else.
|
||||||
* (initial load, via deferEagerFetch) and after cache
|
*
|
||||||
* invalidation so that controller subscribers receive
|
* A failed fetch is reported by whichever view asked for the data
|
||||||
* fresh data on the next requestUpdate() cycle without
|
* (it is that panel's failure, not the app's), but this refetch has
|
||||||
* needing their own LibraryScanComplete listener.
|
* no caller to reject to — without a catch it is an unhandled
|
||||||
|
* rejection.
|
||||||
*/
|
*/
|
||||||
private eagerFetch(): void {
|
private refetchLoaded(loaded: Partial<Record<ViewName, boolean>>): void {
|
||||||
// A failed fetch is reported by whichever view asked for the
|
const logged = this.failureReporter();
|
||||||
// data (it is that panel's failure, not the app's), but the
|
|
||||||
// eager refetch has no caller to reject to — without a catch it
|
|
||||||
// is an unhandled rejection.
|
|
||||||
const logged = (what: string) => (err: unknown) =>
|
|
||||||
console.error(`library: could not load ${what}`, err);
|
|
||||||
|
|
||||||
void this.getTracks().catch(logged('tracks'));
|
if (loaded.tracks) void this.getTracks().catch(logged('tracks'));
|
||||||
void this.getAlbums().catch(logged('albums'));
|
if (loaded.albums) void this.getAlbums().catch(logged('albums'));
|
||||||
void this.getArtists().catch(logged('artists'));
|
if (loaded.artists) void this.getArtists().catch(logged('artists'));
|
||||||
void this.getGenres().catch(logged('genres'));
|
if (loaded.genres) void this.getGenres().catch(logged('genres'));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ import '@components/library-filter/library-filter';
|
|||||||
import '@components/library-status-indicator/library-status-indicator';
|
import '@components/library-status-indicator/library-status-indicator';
|
||||||
import { Events } from '../../src/events';
|
import { Events } from '../../src/events';
|
||||||
import { activeViewStore } from '@store/active-view-store';
|
import { activeViewStore } from '@store/active-view-store';
|
||||||
import { emit, stub, flush, calls, lastArgs } from '@test/support/harness';
|
import { libraryStore } from '@store/library-store';
|
||||||
|
import { emit, stub, flush, calls, lastArgs, resetHarness } from '@test/support/harness';
|
||||||
import {
|
import {
|
||||||
fixture,
|
fixture,
|
||||||
shadow,
|
shadow,
|
||||||
@@ -181,6 +182,14 @@ describe('<library-filter>', () => {
|
|||||||
it('selects a library by id, and the merged view by empty string', async () => {
|
it('selects a library by id, and the merged view by empty string', async () => {
|
||||||
const el = await fixture('library-filter');
|
const el = await fixture('library-filter');
|
||||||
|
|
||||||
|
// The list has to be in use for the filter change to refetch it
|
||||||
|
// (#280): a collection nothing has loaded is not loaded to correct
|
||||||
|
// it, so this is the state the app is in when the Tracks view is up.
|
||||||
|
stub('library.Library.GetTrackTable', { strings: [''] });
|
||||||
|
await libraryStore.getTracks();
|
||||||
|
resetHarness();
|
||||||
|
stub('library.Library.GetTrackTable', { strings: [''] });
|
||||||
|
|
||||||
await flush();
|
await flush();
|
||||||
await el.updateComplete;
|
await el.updateComplete;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
/**
|
||||||
|
* #280: the app loads the library when a view needs it, not at startup.
|
||||||
|
*
|
||||||
|
* Every collection used to be fetched together at `DOMContentLoaded`
|
||||||
|
* and refetched on every invalidation, whether or not anything was
|
||||||
|
* showing it. On a 26 138-track library the track list was 20.5 MB of
|
||||||
|
* that and cost the backend ~170 MB of transient allocation — for
|
||||||
|
* someone looking at Home, which draws none of it.
|
||||||
|
*
|
||||||
|
* **This file must not load the track list before the first test.** It
|
||||||
|
* asserts the state the app is actually left in by its own startup, so
|
||||||
|
* a test added above that asks for tracks would invalidate the
|
||||||
|
* precondition rather than silently pass.
|
||||||
|
*/
|
||||||
|
import { describe, expect, it, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
import { libraryStore } from '@store/library-store';
|
||||||
|
import { Events } from '../../src/events';
|
||||||
|
import {
|
||||||
|
calls,
|
||||||
|
emit,
|
||||||
|
flush,
|
||||||
|
resetHarness,
|
||||||
|
stub,
|
||||||
|
} from '@test/support/harness';
|
||||||
|
|
||||||
|
const TRACKS = 'library.Library.GetTrackTable';
|
||||||
|
const SMALL = [
|
||||||
|
'library.Library.GetAlbums',
|
||||||
|
'library.Library.GetArtists',
|
||||||
|
'library.Library.GetGenres',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for the store's own idle warm-up to land.
|
||||||
|
*
|
||||||
|
* It asks for the three small collections on `requestIdleCallback`, so
|
||||||
|
* when it has happened is the browser's decision — waiting for the
|
||||||
|
* effect rather than for a duration is the only way this is not a race.
|
||||||
|
*/
|
||||||
|
function stubReads(): void {
|
||||||
|
stub(TRACKS, { strings: [''] });
|
||||||
|
for (const path of SMALL) stub(path, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForWarm(): Promise<void> {
|
||||||
|
for (let i = 0; i < 200 && libraryStore.getCachedAlbums() === null; i++) {
|
||||||
|
await flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(libraryStore.getCachedAlbums(), 'the warm-up ran').not.toBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('what the app loads on its own (#280)', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
stubReads();
|
||||||
|
await waitForWarm();
|
||||||
|
// resetHarness clears the stubs as well as the recorded calls.
|
||||||
|
resetHarness();
|
||||||
|
stubReads();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the track list alone', () => {
|
||||||
|
expect(libraryStore.getCachedTracks()).toBeNull();
|
||||||
|
expect(calls(TRACKS)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not load it to answer a scan', async () => {
|
||||||
|
emit(Events.LibraryScanComplete);
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
// The small collections are in use, so they are refreshed; the
|
||||||
|
// track list nobody has opened is not.
|
||||||
|
expect(calls().map((c) => c.path).sort()).toEqual([...SMALL].sort());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads it for the view that draws it, and refreshes it from then on', async () => {
|
||||||
|
libraryStore.prefetch('tracks');
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(calls(TRACKS)).toHaveLength(1);
|
||||||
|
expect(libraryStore.getCachedTracks()).not.toBeNull();
|
||||||
|
|
||||||
|
resetHarness();
|
||||||
|
emit(Events.LibraryScanComplete);
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(calls(TRACKS), 'in use now, so a scan refreshes it').toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is asked for nothing by a prefetch for a view it has no data for', async () => {
|
||||||
|
libraryStore.prefetch('home');
|
||||||
|
libraryStore.prefetch('settings');
|
||||||
|
libraryStore.prefetch('explore');
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(calls()).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -43,16 +43,25 @@ function stubReads(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Drop the cache and let the eager refetch settle, so each test starts
|
* Start each test from a loaded store. The store has no reset of its
|
||||||
* from the same place. The store has no reset of its own; a scan
|
* own; a scan completing is how the app itself clears it — and since
|
||||||
* completing is how the app itself clears it.
|
* #280 it reloads only what something had loaded, so the four reads
|
||||||
|
* here are what says "this test is about a loaded store".
|
||||||
*/
|
*/
|
||||||
async function reload(): Promise<void> {
|
async function reload(): Promise<void> {
|
||||||
stubReads();
|
stubReads();
|
||||||
emit(Events.LibraryScanComplete);
|
emit(Events.LibraryScanComplete);
|
||||||
await flush();
|
await flush();
|
||||||
// The eager refetch the invalidation kicks off is recorded like any
|
resetHarness();
|
||||||
// other call; clear it, or every count in every test is off by one.
|
stubReads();
|
||||||
|
await Promise.all([
|
||||||
|
libraryStore.getTracks(),
|
||||||
|
libraryStore.getAlbums(),
|
||||||
|
libraryStore.getArtists(),
|
||||||
|
libraryStore.getGenres(),
|
||||||
|
]);
|
||||||
|
// Those reads are recorded like any other call; clear them, or every
|
||||||
|
// count in every test is off by one.
|
||||||
resetHarness();
|
resetHarness();
|
||||||
stubReads();
|
stubReads();
|
||||||
}
|
}
|
||||||
@@ -91,7 +100,7 @@ describe('library store: caching', () => {
|
|||||||
]).toEqual([['/a.mp3', '/b.mp3'], ALBUMS, ARTISTS, GENRES]);
|
]).toEqual([['/a.mp3', '/b.mp3'], ALBUMS, ARTISTS, GENRES]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('refetches everything when a scan completes', async () => {
|
it('refetches everything a view had loaded when a scan completes', async () => {
|
||||||
emit(Events.LibraryScanComplete);
|
emit(Events.LibraryScanComplete);
|
||||||
await flush();
|
await flush();
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user