diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 53131c7..7b3d62f 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -229,9 +229,10 @@ import ResetPassword from '@/partials/ResetPassword.vue'; import SecondaryButton from '@/elements/SecondaryButton.vue'; import SetlistSet from '@/modals/SetlistSet.vue'; import SongSet from '@/modals/SongSet.vue'; -import { amberClient, type AmberClient, type UserInTenant, type AmberCollections, type AmberCollection, type UserInfo } from 'amber-client'; +import { amberClient, type AmberClient, type UserInTenant, type AmberCollections, type AmberCollection, type UserInfo, type CollectionDocument } from 'amber-client'; import { SongTag, can, UserRole } from "@backend/definitions"; import type { Song, Setlist, SongEntity, SetlistEntity } from '@backend/models'; +import { bufferAndDefer } from '@/utils.js'; // icons import { @@ -397,7 +398,9 @@ const stayLoggedIn = ref(true); // Data const songs = ref([]); +const songsById = new Map(); // For song existence lookup const setlists = ref([]); +const setlistsById = new Map(); // For setlist existence lookup const users = ref>({}); // { [user id]: user info object } const tags = Object.values(SongTag); @@ -453,29 +456,45 @@ const init = async () => { songsCollection.value = collectionApi.getCollection('songs'); setlistCollection.value = collectionApi.getCollection('setlists'); - songsCollection.value.subscribe(0, (doc) => { - let existing = songs.value.find(s => s.id === doc.id); - if (existing) { - existing.entity = doc.data; - existing.changeNumber = doc.change_number; - } else { - songs.value.push({ id: doc.id, entity: doc.data, changeNumber: doc.change_number }); + songsCollection.value.subscribe(0, bufferAndDefer>((docs) => { + const documentsToAdd: Song[] = []; + for (const doc of docs) { + const existing = songsById.get(doc.id); + if (existing) { + existing.entity = doc.data; + existing.changeNumber = doc.change_number; + } else { + const song: Song = { id: doc.id, entity: doc.data, changeNumber: doc.change_number }; + songsById.set(doc.id, song); + documentsToAdd.push(song); + } } - - }, (docDeletedId) => { + if (documentsToAdd.length > 0) { + songs.value.push(...documentsToAdd); + } + }), (docDeletedId) => { + songsById.delete(docDeletedId); songs.value = songs.value.filter(s => s.id !== docDeletedId); }); - setlistCollection.value.subscribe(0, (doc) => { - let existing = setlists.value.find(s => s.id === doc.id); - if (existing) { - existing.entity = doc.data; - existing.changeNumber = doc.change_number; - } else { - setlists.value.push({ id: doc.id, entity: doc.data, changeNumber: doc.change_number }); + setlistCollection.value.subscribe(0, bufferAndDefer>((docs) => { + const documentsToAdd: Setlist[] = []; + for (const doc of docs) { + const existing = setlistsById.get(doc.id); + if (existing) { + existing.entity = doc.data; + existing.changeNumber = doc.change_number; + } else { + const setlist: Setlist = { id: doc.id, entity: doc.data, changeNumber: doc.change_number }; + setlistsById.set(doc.id, setlist); + documentsToAdd.push(setlist); + } } - - }, (docDeletedId) => { + if (documentsToAdd.length > 0) { + setlists.value.push(...documentsToAdd); + } + }), (docDeletedId) => { + setlistsById.delete(docDeletedId); setlists.value = setlists.value.filter(s => s.id !== docDeletedId); }); diff --git a/frontend/src/utils.ts b/frontend/src/utils.ts index 3cd6f0a..fd0a1e1 100644 --- a/frontend/src/utils.ts +++ b/frontend/src/utils.ts @@ -22,6 +22,24 @@ const isChordLine = (line: string): boolean => { const isSlide = (entry: SetlistEntry | SetlistPresentationEntry): entry is SetlistSlide => !('id' in entry) && !('slug' in entry); +// batch values arriving in quick succession into a single handler call, deferredCallDelay ms after the +// first value of a burst arrives - collapses rapid-fire callbacks (e.g. one per network message) into one +// downstream update per burst instead of one per value +function bufferAndDefer(handler: (buffer: T[]) => void, deferredCallDelay: number = 20): (value: T) => void { + let buffer: T[] = []; + return (value: T) => { + buffer.push(value); + if (buffer.length == 1) { + // only schedule one deferred call to handler if this is the first item in the buffer to avoid multiple calls + setTimeout(() => { + handler(buffer); + // clear buffer after handling the chunk of items + buffer = []; + }, deferredCallDelay); + } + } +} + // grow/shrink the font size of every
 inside every .present element as large as possible while
 // still fitting its parent's width and, per .present group, the viewport's height.
 // Non-wrapping text (songs) and wrapping text (slides) are taken into account.
@@ -503,6 +521,7 @@ export {
   keyScale,
   isChordLine,
   isSlide,
+  bufferAndDefer,
   maximizePresentFontsize,
   parsedContent,
   download,
diff --git a/frontend/tests/utils.test.ts b/frontend/tests/utils.test.ts
index ce79566..c2abf74 100644
--- a/frontend/tests/utils.test.ts
+++ b/frontend/tests/utils.test.ts
@@ -1,6 +1,7 @@
-import { describe, expect, it } from 'vitest';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
 import type { SongEntity } from '@backend/models';
 import {
+	bufferAndDefer,
 	firstParam,
 	humanDate,
 	humanFileSize,
@@ -14,6 +15,68 @@ import {
 	urlify,
 } from '@/utils';
 
+describe('bufferAndDefer', () => {
+	beforeEach(() => {
+		vi.useFakeTimers();
+	});
+
+	afterEach(() => {
+		vi.useRealTimers();
+	});
+
+	it('defers the handler call until after the delay', () => {
+		const handler = vi.fn();
+		const push = bufferAndDefer(handler, 20);
+		push('a');
+		expect(handler).not.toHaveBeenCalled();
+		vi.advanceTimersByTime(20);
+		expect(handler).toHaveBeenCalledTimes(1);
+	});
+
+	it('batches values arriving within the delay window into a single call', () => {
+		const handler = vi.fn();
+		const push = bufferAndDefer(handler, 20);
+		push('a');
+		push('b');
+		push('c');
+		vi.advanceTimersByTime(20);
+		expect(handler).toHaveBeenCalledTimes(1);
+		expect(handler).toHaveBeenCalledWith(['a', 'b', 'c']);
+	});
+
+	it('starts a new batch once the previous one has flushed', () => {
+		const handler = vi.fn();
+		const push = bufferAndDefer(handler, 20);
+		push('a');
+		vi.advanceTimersByTime(20);
+		push('b');
+		vi.advanceTimersByTime(20);
+		expect(handler).toHaveBeenCalledTimes(2);
+		expect(handler).toHaveBeenNthCalledWith(1, ['a']);
+		expect(handler).toHaveBeenNthCalledWith(2, ['b']);
+	});
+
+	it('defaults the delay to 20ms', () => {
+		const handler = vi.fn();
+		const push = bufferAndDefer(handler);
+		push('a');
+		vi.advanceTimersByTime(19);
+		expect(handler).not.toHaveBeenCalled();
+		vi.advanceTimersByTime(1);
+		expect(handler).toHaveBeenCalledTimes(1);
+	});
+
+	it('respects a custom delay', () => {
+		const handler = vi.fn();
+		const push = bufferAndDefer(handler, 100);
+		push('a');
+		vi.advanceTimersByTime(99);
+		expect(handler).not.toHaveBeenCalled();
+		vi.advanceTimersByTime(1);
+		expect(handler).toHaveBeenCalledTimes(1);
+	});
+});
+
 describe('isChordLine', () => {
 	it('rejects empty and whitespace-only lines', () => {
 		expect(isChordLine('')).toBe(false);