Skip to content

Commit 2bc77a0

Browse files
author
Diocrafts
committed
perf(photos): append-only render eliminates DOM rebuild on scroll
Replace the innerHTML full-rebuild in _render() with two paths: - _renderFull(): used for first load, group-mode change, and deletions - _appendBatch(n): append-only for infinite-scroll pages — O(batch) instead of O(total). Existing <img> nodes are never destroyed, eliminating the visual flash and unnecessary DOM churn. Also: - Extract _renderTile() helper (DRY tile HTML generation) - Extract _observeSentinel() helper - Scope _setupVideoThumbnails(startIndex) to only process new tiles - Add data-group attribute on headers for efficient CSS.escape lookup - Fix stale WebP references in comments (now JPEG) - Add virtual scrolling idea to TODO-LIST.md for future evaluation
1 parent b8638f5 commit 2bc77a0

File tree

2 files changed

+116
-47
lines changed

2 files changed

+116
-47
lines changed

TODO-LIST.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ This document contains the task list for the development of OxiCloud, a minimali
3434
- [x] Implement multiple file uploads
3535
- [ ] Add progress indicators for long operations
3636
- [x] Implement UI notifications for events
37+
- [ ] Photos timeline: virtual scrolling
38+
- Solo mantener en el DOM las filas visibles en el viewport + un margen. Al hacer scroll, reciclar los nodos que salen por arriba para los que entran por abajo.
39+
- Ventajas: Funciona perfectamente con 50,000 fotos. Uso de memoria constante.
40+
- Excesiva para ahora — evaluar cuando el volumen de fotos lo justifique.
3741

3842
## Phase 2: Authentication and Multi-User
3943

static/js/features/library/photos.js

Lines changed: 112 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ const photosView = {
3030
_activeDecodes: 0,
3131
/** @type {Array} Pending video decode queue */
3232
_decodeQueue: [],
33+
/** @type {number} Items already rendered in the DOM */
34+
_renderedCount: 0,
3335

3436
PAGE_SIZE: 200,
3537

@@ -66,7 +68,8 @@ const photosView = {
6668
this.nextCursor = null;
6769
this.exhausted = false;
6870
this.selected.clear();
69-
this._render();
71+
this._renderedCount = 0;
72+
this._container.innerHTML = '';
7073
this._loadPage();
7174
},
7275

@@ -84,14 +87,16 @@ const photosView = {
8487
if (this.groupMode === mode) return;
8588
this.groupMode = mode;
8689
localStorage.setItem('oxicloud-photos-group', mode);
87-
this._render();
90+
this._renderedCount = 0;
91+
this._renderFull();
8892
},
8993

9094
/** Fetch a page of photos from the API */
9195
async _loadPage() {
9296
if (this.loading || this.exhausted) return;
9397
this.loading = true;
9498
this._showLoading(true);
99+
const prevCount = this.items.length;
95100

96101
try {
97102
let url = `/api/photos?limit=${this.PAGE_SIZE}`;
@@ -125,74 +130,129 @@ const photosView = {
125130
} finally {
126131
this.loading = false;
127132
this._showLoading(false);
128-
this._render();
133+
if (prevCount === 0) {
134+
this._renderFull();
135+
} else {
136+
this._appendBatch(prevCount);
137+
}
129138
}
130139
},
131140

132-
/** Render the full timeline from this.items */
133-
_render() {
141+
// ── Rendering ───────────────────────────────────────────────────
142+
// Two render paths:
143+
// _renderFull() — full DOM rebuild (first load, group-mode change, delete)
144+
// _appendBatch(n) — append-only for infinite-scroll pages (O(batch))
145+
146+
/** Full DOM rebuild — first load, group-mode switch, or after deletions. */
147+
_renderFull() {
134148
if (!this._container) return;
135149
this._destroyObserver();
136150

137-
// Set group mode class on container
138151
this._container.classList.remove('photos-group-daily', 'photos-group-monthly', 'photos-group-yearly');
139152
this._container.classList.add(`photos-group-${this.groupMode}`);
140153

141154
if (this.items.length === 0 && this.exhausted) {
142155
this._renderEmpty();
143156
return;
144157
}
145-
146158
if (this.items.length === 0) return;
147159

148-
// Group by selected mode
149160
const groups = this._groupItems(this.items);
150161
let html = this._renderToolbar();
151162

152163
for (const [label, files] of groups) {
153-
html += `<div class="photos-day-header">${this._escHtml(label)}<span class="photos-day-count">${files.length}</span></div>`;
164+
html += `<div class="photos-day-header" data-group="${this._escAttr(label)}">${this._escHtml(label)}<span class="photos-day-count">${files.length}</span></div>`;
154165
html += '<div class="photos-grid">';
155-
for (const file of files) {
156-
const isVideo = file.mime_type && file.mime_type.startsWith('video/');
157-
const selected = this.selected.has(file.id) ? ' selected' : '';
158-
// For videos with a cached local thumb, use it directly;
159-
// avoids the 204 → error → re-decode cycle on re-renders.
160-
const cachedThumb = isVideo && this._videoThumbCache.has(file.id)
161-
? this._videoThumbCache.get(file.id)
162-
: null;
163-
const thumbUrl = cachedThumb || `/api/files/${file.id}/thumbnail/preview`;
164-
html += `<div class="photo-tile${selected}" data-id="${this._escAttr(file.id)}" data-mime="${this._escAttr(file.mime_type)}">`;
165-
html += `<div class="photo-check"><i class="fas fa-check"></i></div>`;
166-
html += `<img src="${thumbUrl}" loading="lazy" alt="${this._escAttr(file.name)}">`;
167-
if (isVideo) {
168-
html += `<div class="video-badge"><i class="fas fa-play"></i></div>`;
169-
}
170-
html += `</div>`;
171-
}
166+
for (const file of files) html += this._renderTile(file);
172167
html += '</div>';
173168
}
174169

175-
// Sentinel for infinite scroll
176170
html += '<div class="photos-sentinel"></div>';
177-
178171
this._container.innerHTML = html;
179-
180-
// Attach click handlers via delegation
181172
this._container.onclick = (e) => this._handleClick(e);
173+
this._renderedCount = this.items.length;
174+
this._observeSentinel();
175+
this._setupVideoThumbnails();
176+
},
182177

183-
// Observe sentinel for infinite scroll
178+
/** Append-only render for infinite scroll — inserts only the items
179+
* from this.items[startIndex..] without destroying existing DOM.
180+
* Complexity: O(batch) instead of O(total_items). */
181+
_appendBatch(startIndex) {
182+
if (!this._container) return;
183+
this._destroyObserver();
184+
185+
const newItems = this.items.slice(startIndex);
186+
if (newItems.length === 0) {
187+
this._observeSentinel();
188+
return;
189+
}
190+
191+
const newGroups = this._groupItems(newItems);
184192
const sentinel = this._container.querySelector('.photos-sentinel');
193+
if (!sentinel) {
194+
// Fallback: sentinel missing — full rebuild
195+
this._renderedCount = 0;
196+
this._renderFull();
197+
return;
198+
}
199+
200+
for (const [label, files] of newGroups) {
201+
let tilesHtml = '';
202+
for (const file of files) tilesHtml += this._renderTile(file);
203+
204+
// Does this date-group already exist in the DOM?
205+
const existingHeader = this._container.querySelector(
206+
`.photos-day-header[data-group="${CSS.escape(label)}"]`
207+
);
208+
209+
if (existingHeader) {
210+
// Append tiles to existing grid and update count badge
211+
const grid = existingHeader.nextElementSibling;
212+
if (grid && grid.classList.contains('photos-grid')) {
213+
grid.insertAdjacentHTML('beforeend', tilesHtml);
214+
const countSpan = existingHeader.querySelector('.photos-day-count');
215+
if (countSpan) countSpan.textContent = grid.children.length;
216+
}
217+
} else {
218+
// New group — insert header + grid before sentinel
219+
const sectionHtml =
220+
`<div class="photos-day-header" data-group="${this._escAttr(label)}">${this._escHtml(label)}<span class="photos-day-count">${files.length}</span></div>` +
221+
`<div class="photos-grid">${tilesHtml}</div>`;
222+
sentinel.insertAdjacentHTML('beforebegin', sectionHtml);
223+
}
224+
}
225+
226+
this._renderedCount = this.items.length;
227+
this._observeSentinel();
228+
this._setupVideoThumbnails(startIndex);
229+
},
230+
231+
/** Generate HTML for a single photo/video tile */
232+
_renderTile(file) {
233+
const isVideo = file.mime_type && file.mime_type.startsWith('video/');
234+
const selected = this.selected.has(file.id) ? ' selected' : '';
235+
const cachedThumb = isVideo && this._videoThumbCache.has(file.id)
236+
? this._videoThumbCache.get(file.id) : null;
237+
const thumbUrl = cachedThumb || `/api/files/${file.id}/thumbnail/preview`;
238+
let h = `<div class="photo-tile${selected}" data-id="${this._escAttr(file.id)}" data-mime="${this._escAttr(file.mime_type)}">`;
239+
h += `<div class="photo-check"><i class="fas fa-check"></i></div>`;
240+
h += `<img src="${thumbUrl}" loading="lazy" alt="${this._escAttr(file.name)}">`;
241+
if (isVideo) h += `<div class="video-badge"><i class="fas fa-play"></i></div>`;
242+
h += `</div>`;
243+
return h;
244+
},
245+
246+
/** (Re-)observe the sentinel element for infinite scroll */
247+
_observeSentinel() {
248+
this._destroyObserver();
249+
const sentinel = this._container?.querySelector('.photos-sentinel');
185250
if (sentinel && !this.exhausted) {
186251
this._observer = new IntersectionObserver((entries) => {
187-
if (entries[0].isIntersecting) {
188-
this._loadPage();
189-
}
252+
if (entries[0].isIntersecting) this._loadPage();
190253
}, { rootMargin: '400px' });
191254
this._observer.observe(sentinel);
192255
}
193-
194-
// Set up client-side video thumbnail generation
195-
this._setupVideoThumbnails();
196256
},
197257

198258
// ── Client-side video thumbnail generation ──────────────────────
@@ -202,18 +262,22 @@ const photosView = {
202262

203263
/** Attach error handlers to video tile images; on failure, extract a
204264
* frame from the video using the browser's built-in codec. */
205-
_setupVideoThumbnails() {
265+
/** @param {number} [startIndex=0] When > 0, only process video tiles
266+
* for items[startIndex..] — avoids re-scanning the entire DOM. */
267+
_setupVideoThumbnails(startIndex = 0) {
206268
const tiles = this._container.querySelectorAll('.photo-tile[data-mime^="video/"]');
269+
const newIds = startIndex > 0
270+
? new Set(this.items.slice(startIndex).map(f => f.id))
271+
: null;
272+
207273
for (const tile of tiles) {
208-
const img = tile.querySelector('img');
209-
if (!img) continue;
210274
const fileId = tile.dataset.id;
211-
212-
// Skip if already cached locally (URL was set during render)
275+
if (newIds && !newIds.has(fileId)) continue;
213276
if (this._videoThumbCache.has(fileId)) continue;
214277

215-
// The server returns 204 for videos without a cached thumbnail,
216-
// which causes the <img> to fire 'error'.
278+
const img = tile.querySelector('img');
279+
if (!img) continue;
280+
217281
img.addEventListener('error', () => {
218282
this._enqueueVideoThumbnail(tile, img);
219283
}, { once: true });
@@ -241,7 +305,7 @@ const photosView = {
241305
},
242306

243307
/** Extract a single frame from a video and display it as the tile
244-
* thumbnail, then upload the WebP to the server for caching. */
308+
* thumbnail, then upload the JPEG to the server for caching. */
245309
_generateVideoThumbnail(tile, img) {
246310
const fileId = tile.dataset.id;
247311
const video = document.createElement('video');
@@ -299,7 +363,7 @@ const photosView = {
299363
if (resp.ok) {
300364
// Switch from blob URL to server URL so the blob
301365
// can be garbage-collected and future loads use
302-
// the permanently cached WebP from the server.
366+
// the permanently cached JPEG from the server.
303367
const serverUrl = `/api/files/${fileId}/thumbnail/preview?v=1`;
304368
this._videoThumbCache.set(fileId, serverUrl);
305369
}
@@ -457,7 +521,8 @@ const photosView = {
457521
this.items = this.items.filter(f => !this.selected.has(f.id));
458522
this.selected.clear();
459523
this._hideSelectionBar();
460-
this._render();
524+
this._renderedCount = 0;
525+
this._renderFull();
461526
};
462527

463528
bar.querySelector('#photos-sel-download').onclick = async () => {

0 commit comments

Comments
 (0)