Skip to content

Commit ec25d06

Browse files
perf(frontend): batch Alpine.js reactive updates in library store mutations
Wrap sequential reactive property assignments in Alpine.disableEffectScheduling() so computed getters (visibleTracks, totalContentHeight, startIndex, endIndex, offsetY) re-evaluate once per operation instead of 5-6 times. Batched functions: applySectionData, loadLibraryData, backgroundRefreshLibrary, removeTracksLocallyOp. Inlined applyFilters() into batch blocks. Removed unused tracksByArtist/tracksByAlbum getters and their tests. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 3365bc0 commit ec25d06

4 files changed

Lines changed: 133 additions & 185 deletions

File tree

app/frontend/__tests__/library.store.test.js

Lines changed: 5 additions & 134 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
* Tauri backend or API mocking.
66
*/
77

8-
import { describe, it, expect, beforeEach, vi } from 'vitest';
9-
import { test, fc } from '@fast-check/vitest';
8+
import { beforeEach, describe, expect, it, vi } from 'vitest';
9+
import { fc, test } from '@fast-check/vitest';
1010

1111
// -----------------------------------------------------------------------------
1212
// Test Helpers: Create isolated library store instances for testing
@@ -59,38 +59,6 @@ function createTestLibraryStore(initialTracks = []) {
5959
return Array.from(albumSet).sort();
6060
},
6161

62-
/**
63-
* Get tracks grouped by artist
64-
* Uses Object.create(null) to avoid prototype pollution with artist names like "toString"
65-
*/
66-
get tracksByArtist() {
67-
const grouped = Object.create(null);
68-
for (const track of this.filteredTracks) {
69-
const artist = track.artist || 'Unknown Artist';
70-
if (!grouped[artist]) {
71-
grouped[artist] = [];
72-
}
73-
grouped[artist].push(track);
74-
}
75-
return grouped;
76-
},
77-
78-
/**
79-
* Get tracks grouped by album
80-
* Uses Object.create(null) to avoid prototype pollution with album names like "valueOf"
81-
*/
82-
get tracksByAlbum() {
83-
const grouped = Object.create(null);
84-
for (const track of this.filteredTracks) {
85-
const album = track.album || 'Unknown Album';
86-
if (!grouped[album]) {
87-
grouped[album] = [];
88-
}
89-
grouped[album].push(track);
90-
}
91-
return grouped;
92-
},
93-
9462
/**
9563
* Get track by ID
9664
* @param {string} trackId - Track ID
@@ -120,7 +88,6 @@ const trackArb = fc.record({
12088
/** Generate an array of tracks */
12189
const tracksArb = fc.array(trackArb, { minLength: 0, maxLength: 30 });
12290

123-
12491
// -----------------------------------------------------------------------------
12592
// Tests: formattedTotalDuration Getter
12693
// -----------------------------------------------------------------------------
@@ -165,7 +132,7 @@ describe('Library Store - formattedTotalDuration', () => {
165132

166133
// Should match either "Xh Ym" or "X min"
167134
expect(result).toMatch(/^(\d+h \d+m|\d+ min)$/);
168-
}
135+
},
169136
);
170137

171138
test.prop([fc.integer({ min: 0, max: 59 * 60 * 1000 })])(
@@ -176,7 +143,7 @@ describe('Library Store - formattedTotalDuration', () => {
176143
const result = store.formattedTotalDuration;
177144

178145
expect(result).toMatch(/^\d+ min$/);
179-
}
146+
},
180147
);
181148

182149
test.prop([fc.integer({ min: 60 * 60 * 1000, max: 100 * 60 * 60 * 1000 })])(
@@ -187,7 +154,7 @@ describe('Library Store - formattedTotalDuration', () => {
187154
const result = store.formattedTotalDuration;
188155

189156
expect(result).toMatch(/^\d+h \d+m$/);
190-
}
157+
},
191158
);
192159
});
193160

@@ -282,101 +249,6 @@ describe('Library Store - albums getter', () => {
282249
});
283250
});
284251

285-
// -----------------------------------------------------------------------------
286-
// Tests: tracksByArtist and tracksByAlbum Getters
287-
// -----------------------------------------------------------------------------
288-
289-
describe('Library Store - tracksByArtist getter', () => {
290-
it('returns empty object for empty library', () => {
291-
const store = createTestLibraryStore([]);
292-
expect(store.tracksByArtist).toEqual({});
293-
});
294-
295-
it('groups tracks by artist', () => {
296-
const tracks = [
297-
{ id: '1', artist: 'Artist A', title: 'Song 1' },
298-
{ id: '2', artist: 'Artist B', title: 'Song 2' },
299-
{ id: '3', artist: 'Artist A', title: 'Song 3' },
300-
];
301-
const store = createTestLibraryStore(tracks);
302-
const grouped = store.tracksByArtist;
303-
304-
expect(Object.keys(grouped)).toEqual(['Artist A', 'Artist B']);
305-
expect(grouped['Artist A'].length).toBe(2);
306-
expect(grouped['Artist B'].length).toBe(1);
307-
});
308-
309-
it('uses "Unknown Artist" for tracks without artist', () => {
310-
const tracks = [
311-
{ id: '1', artist: null, title: 'Song 1' },
312-
{ id: '2', artist: '', title: 'Song 2' },
313-
{ id: '3', artist: undefined, title: 'Song 3' },
314-
];
315-
const store = createTestLibraryStore(tracks);
316-
const grouped = store.tracksByArtist;
317-
318-
expect(Object.keys(grouped)).toEqual(['Unknown Artist']);
319-
expect(grouped['Unknown Artist'].length).toBe(3);
320-
});
321-
322-
test.prop([tracksArb])('total tracks equals sum of grouped tracks', (tracks) => {
323-
const store = createTestLibraryStore(tracks);
324-
const grouped = store.tracksByArtist;
325-
const totalGrouped = Object.values(grouped).reduce((sum, arr) => sum + arr.length, 0);
326-
expect(totalGrouped).toBe(tracks.length);
327-
});
328-
329-
test.prop([tracksArb])('each track appears exactly once', (tracks) => {
330-
const store = createTestLibraryStore(tracks);
331-
const grouped = store.tracksByArtist;
332-
const allGroupedIds = Object.values(grouped)
333-
.flat()
334-
.map((t) => t.id);
335-
const uniqueIds = new Set(allGroupedIds);
336-
expect(uniqueIds.size).toBe(tracks.length);
337-
});
338-
});
339-
340-
describe('Library Store - tracksByAlbum getter', () => {
341-
it('returns empty object for empty library', () => {
342-
const store = createTestLibraryStore([]);
343-
expect(store.tracksByAlbum).toEqual({});
344-
});
345-
346-
it('groups tracks by album', () => {
347-
const tracks = [
348-
{ id: '1', album: 'Album A', title: 'Song 1' },
349-
{ id: '2', album: 'Album B', title: 'Song 2' },
350-
{ id: '3', album: 'Album A', title: 'Song 3' },
351-
];
352-
const store = createTestLibraryStore(tracks);
353-
const grouped = store.tracksByAlbum;
354-
355-
expect(Object.keys(grouped)).toEqual(['Album A', 'Album B']);
356-
expect(grouped['Album A'].length).toBe(2);
357-
expect(grouped['Album B'].length).toBe(1);
358-
});
359-
360-
it('uses "Unknown Album" for tracks without album', () => {
361-
const tracks = [
362-
{ id: '1', album: null, title: 'Song 1' },
363-
{ id: '2', album: '', title: 'Song 2' },
364-
];
365-
const store = createTestLibraryStore(tracks);
366-
const grouped = store.tracksByAlbum;
367-
368-
expect(Object.keys(grouped)).toEqual(['Unknown Album']);
369-
expect(grouped['Unknown Album'].length).toBe(2);
370-
});
371-
372-
test.prop([tracksArb])('total tracks equals sum of grouped tracks', (tracks) => {
373-
const store = createTestLibraryStore(tracks);
374-
const grouped = store.tracksByAlbum;
375-
const totalGrouped = Object.values(grouped).reduce((sum, arr) => sum + arr.length, 0);
376-
expect(totalGrouped).toBe(tracks.length);
377-
});
378-
});
379-
380252
// -----------------------------------------------------------------------------
381253
// Tests: getTrack Method
382254
// -----------------------------------------------------------------------------
@@ -414,7 +286,6 @@ describe('Library Store - getTrack', () => {
414286
});
415287
});
416288

417-
418289
// -----------------------------------------------------------------------------
419290
// Tests: Statistics and State
420291
// -----------------------------------------------------------------------------

app/frontend/js/stores/library.js

Lines changed: 0 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -415,30 +415,6 @@ export function createLibraryStore(Alpine) {
415415
return Array.from(albumSet).sort();
416416
},
417417

418-
get tracksByArtist() {
419-
const grouped = {};
420-
for (const track of this.filteredTracks) {
421-
const artist = track.artist || 'Unknown Artist';
422-
if (!grouped[artist]) {
423-
grouped[artist] = [];
424-
}
425-
grouped[artist].push(track);
426-
}
427-
return grouped;
428-
},
429-
430-
get tracksByAlbum() {
431-
const grouped = {};
432-
for (const track of this.filteredTracks) {
433-
const album = track.album || 'Unknown Album';
434-
if (!grouped[album]) {
435-
grouped[album] = [];
436-
}
437-
grouped[album].push(track);
438-
}
439-
return grouped;
440-
},
441-
442418
// -----------------------------------------------------------------------
443419
// Rescan and scan progress
444420
// -----------------------------------------------------------------------

app/frontend/js/utils/library-operations.js

Lines changed: 46 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,13 @@ import { promptToAddWatchedFolders } from '../utils/watched-folders.js';
1818
* Shared between loadSection and backgroundRefreshSection.
1919
*/
2020
export function applySectionData(store, section, tracks, data) {
21-
store.tracks = tracks;
22-
store.totalTracks = data?.total ?? tracks.length;
23-
store.totalDuration = tracks.reduce((sum, t) => sum + (t.duration || 0), 0);
24-
store._lastLoadedSection = section;
25-
store.applyFilters();
21+
window.Alpine.disableEffectScheduling(() => {
22+
store.tracks = tracks;
23+
store.totalTracks = data?.total ?? tracks.length;
24+
store.totalDuration = tracks.reduce((sum, t) => sum + (t.duration || 0), 0);
25+
store._lastLoadedSection = section;
26+
store.filteredTracks = [...tracks];
27+
});
2628
}
2729

2830
/**
@@ -191,19 +193,22 @@ export async function loadLibraryData(store, { forceReload = false } = {}) {
191193
return;
192194
}
193195

194-
store.tracks = data.tracks || [];
196+
const rawTracks = data.tracks || [];
195197
const _t2 = performance.now();
196-
store.totalTracks = data.total || store.tracks.length;
197-
store.totalDuration = store.tracks.reduce((sum, t) => sum + (t.duration || 0), 0);
198-
store._lastLoadedSection = loadSection;
199-
store._updateCache(loadSection, data);
200198

201-
// _fetchLibraryData always returns the full library, so keep allTracks in sync
202-
store.allTracks = store.tracks;
203-
store._dataVersion++;
199+
window.Alpine.disableEffectScheduling(() => {
200+
store.tracks = rawTracks;
201+
store.totalTracks = data.total || rawTracks.length;
202+
store.totalDuration = rawTracks.reduce((sum, t) => sum + (t.duration || 0), 0);
203+
store._lastLoadedSection = loadSection;
204+
store.allTracks = rawTracks;
205+
store._dataVersion++;
206+
store.filteredTracks = [...rawTracks];
207+
});
208+
store._updateCache(loadSection, data);
204209
const _t3 = performance.now();
205210

206-
store.applyFilters();
211+
// applyFilters inlined into the batch above
207212
const _t4 = performance.now();
208213

209214
window._perfLibLoad = {
@@ -243,21 +248,29 @@ export async function backgroundRefreshLibrary(store, section) {
243248
const data = await store._fetchLibraryData();
244249

245250
// _fetchLibraryData always returns the full library
246-
store.allTracks = data.tracks || [];
247-
store._dataVersion++;
251+
const refreshedTracks = data.tracks || [];
248252

249253
// Only update section-specific state if still on same section
250254
if (store.currentSection === section) {
251-
store.tracks = store.allTracks;
252-
store.totalTracks = data.total || store.tracks.length;
253-
store.totalDuration = store.tracks.reduce((sum, t) => sum + (t.duration || 0), 0);
255+
window.Alpine.disableEffectScheduling(() => {
256+
store.allTracks = refreshedTracks;
257+
store._dataVersion++;
258+
store.tracks = refreshedTracks;
259+
store.totalTracks = data.total || refreshedTracks.length;
260+
store.totalDuration = refreshedTracks.reduce((sum, t) => sum + (t.duration || 0), 0);
261+
store.filteredTracks = [...refreshedTracks];
262+
});
254263
store._updateCache(section, data);
255-
store.applyFilters();
256264

257265
console.log('[library] background refresh complete:', {
258266
section,
259267
trackCount: store.tracks.length,
260268
});
269+
} else {
270+
window.Alpine.disableEffectScheduling(() => {
271+
store.allTracks = refreshedTracks;
272+
store._dataVersion++;
273+
});
261274
}
262275
} catch (error) {
263276
// Silent fail for background refresh
@@ -430,15 +443,21 @@ export function removeTracksLocallyOp(store, Alpine, trackIds) {
430443
}
431444

432445
const idSet = new Set(trackIds);
433-
store.allTracks = store.allTracks.filter((t) => !idSet.has(t.id));
434-
store._dataVersion++;
435-
store.tracks = store.tracks.filter((t) => !idSet.has(t.id));
436-
store.totalTracks = store.tracks.length;
437-
store.totalDuration = store.tracks.reduce((sum, t) => sum + (t.duration || 0), 0);
438-
store._clearCache();
446+
const newAllTracks = store.allTracks.filter((t) => !idSet.has(t.id));
447+
const newTracks = store.tracks.filter((t) => !idSet.has(t.id));
439448
// Filter filteredTracks directly — removing items from a sorted list preserves
440449
// sort order, so re-running applyFilters() (O(n log n) sort) is unnecessary.
441-
store.filteredTracks = store.filteredTracks.filter((t) => !idSet.has(t.id));
450+
const newFilteredTracks = store.filteredTracks.filter((t) => !idSet.has(t.id));
451+
452+
window.Alpine.disableEffectScheduling(() => {
453+
store.allTracks = newAllTracks;
454+
store._dataVersion++;
455+
store.tracks = newTracks;
456+
store.totalTracks = newTracks.length;
457+
store.totalDuration = newTracks.reduce((sum, t) => sum + (t.duration || 0), 0);
458+
store.filteredTracks = newFilteredTracks;
459+
});
460+
store._clearCache();
442461

443462
// Remove from queue if present
444463
removeFromQueue(Alpine, idSet);

0 commit comments

Comments
 (0)