web: the strip draws the tiles under the eye, not the shelf — a roll of twenty thousand no longer builds every tile before the first one is seen

Coming back to LIBRARY froze the screen for a second and a half. It was not the tree
— the tree has been up in under a hundred milliseconds all along. It was the strip:
it made a `<button>`, an `<img>` and an object URL for every frame the open node
held, and an object URL costs about a tenth of a millisecond, which is a second of
blocked main thread on a roll of twenty thousand. Six and a half thousand tiles were
built for pictures nobody had scrolled to.

The strip now draws the run under its viewport and `STRIP_KEEP` tiles either side of
it, measured from `scrollLeft` and the client width on scroll and on resize. Two
spacers stand in for the runs that are not drawn, each as wide as the tiles it
replaces, so the scrollbar still measures the shelf rather than the window: the sum
is the same `140n − 8` in every case. The frame on the stage is given its URL
wherever it sits, since the stage is not the strip.

Measured against a seeded roll of 19998 frames: the first tile 1816ms → 663ms on a
cold visit and 1728ms → 681ms coming back from the studio; the long tasks 1302ms
across five of them → 184ms across one. Tiles drawn 6666 → 18, with the scrollbar
unmoved (the far end draws the far frames, and the frame on the stage keeps its
picture once its tile is out of the window).

The wall is the other view and still holds every frame on purpose; it is the one
list left that builds a card per frame.
This commit is contained in:
2026-10-01 11:03:41 +07:00
parent 2c3565cf85
commit fb9c9f98db
2 changed files with 77 additions and 9 deletions
+73 -9
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useReducer, useRef, useState, type CSSProperties } from 'react';
import { useCallback, useEffect, useMemo, useReducer, useRef, useState, type CSSProperties, type RefObject } from 'react';
import { useI18n } from './i18n/I18nProvider';
import { go } from './nav';
import { api } from './api';
@@ -72,6 +72,13 @@ const COLUMN_DEFAULT = 118;
// handful of ticks the thumbnail behind the preview runs out of pixels anyway.
const ZOOM_MAX = 6;
const ZOOM_STEP = 1.15;
// The strip's tile: `.adm-thumb` is 132px wide and `.adm-filmstrip` leaves 8px
// between tiles, so a tile takes 140px of the strip wherever it sits.
const TILE_STEP = 140;
// How many tiles past each edge of the strip are drawn. Enough that a fast drag
// meets pictures rather than placeholders, few enough that the count does not
// matter: what is drawn is this many, whatever the shelf holds.
const STRIP_KEEP = 8;
// The hours a frame's own clock can read, and what the time filter offers: the
// whole day in the 24-hour form a camera writes into EXIF.
const HOURS = Array.from({ length: 24 }, (_, i) => i);
@@ -609,7 +616,21 @@ export function Library() {
}, [deep, node, photos, rootName]);
const frame = shown.find((p) => p.id === up) ?? shown[0] ?? null;
const urls = useThumbUrls(shown);
// The strip draws the tiles around the one under the eye, not the whole shelf.
// A roll is thousands of frames and every tile drawn costs a `<button>`, an
// `<img>` and an object URL of its own — and a URL is about a tenth of a
// millisecond to make, which is a second of blocked main thread for pictures
// nobody has scrolled to. The spacers on either side of the drawn run keep the
// scrollbar measuring the shelf.
const strip = useStripWindow(shown.length, view === 'preview');
const drawn = useMemo(
() => shown.slice(Math.min(strip.from, shown.length), Math.min(strip.to, shown.length)),
[shown, strip.from, strip.to]
);
// The wall is the other list and holds every frame on purpose; the strip's window
// is what the URLs are made for only while the strip is up. The frame on the stage
// is given one wherever it sits — the stage is not the strip.
const urls = useThumbUrls(view === 'preview' ? drawn : shown, frame);
// The years the open folder holds, newest first: the filter offers the years
// there are frames to find in rather than a century of empty ones.
@@ -1151,9 +1172,12 @@ export function Library() {
</div>
{/* The strip: one thumbnail per frame the open node holds, in the
order the catalogue reads them (newest shutter first). */}
order the catalogue reads them (newest shutter first) — the ones
around the eye, with a spacer standing in for each run that is
not drawn, so the scrollbar still measures the whole shelf. */}
{view === 'preview' ? (
<div
ref={strip.ref}
className="adm-filmstrip"
data-key="lib-strip"
// A wheel has no sideways axis, so a plain tick over the strip
@@ -1162,7 +1186,10 @@ export function Library() {
if (!e.deltaX) e.currentTarget.scrollLeft += e.deltaY;
}}
>
{shown.map((photo) => (
{strip.from > 0 ? (
<span className="adm-thumb-gap" style={{ flexBasis: strip.from * TILE_STEP - 8 }} aria-hidden="true" />
) : null}
{drawn.map((photo) => (
<button
key={photo.id}
type="button"
@@ -1175,6 +1202,9 @@ export function Library() {
<span className="adm-thumb-cap">{photo.name}</span>
</button>
))}
{shown.length - strip.to > 0 ? (
<span className="adm-thumb-gap" style={{ flexBasis: (shown.length - strip.to) * TILE_STEP - 8 }} aria-hidden="true" />
) : null}
{shown.length === 0 ? <p className="hint adm-row-empty">{t('lib.empty')}</p> : null}
</div>
) : null}
@@ -1290,9 +1320,10 @@ export function Library() {
}
// One object URL per tile, made when the row does and dropped with it: a folder
// of thousands cannot hold thousands of live URLs, and `shown` is the only list
// the panels paint.
function useThumbUrls(photos: LibraryPhoto[]): Record<string, string> {
// of thousands cannot hold thousands of live URLs, and the list the panels paint is
// the only list to make them for. `also` is a frame drawn outside that list — the
// one on the stage, whose tile may be nowhere near the strip's window.
function useThumbUrls(photos: LibraryPhoto[], also?: LibraryPhoto | null): Record<string, string> {
const [urls, setUrls] = useState<Record<string, string>>({});
// The URL a row was given, kept for as long as the row is on screen. A scan lands
// in batches, and each batch hands the screen the whole list again — so a URL
@@ -1301,8 +1332,9 @@ function useThumbUrls(photos: LibraryPhoto[]): Record<string, string> {
// leaves the list takes its URL with it.
const made = useRef(new Map<string, string>());
useEffect(() => {
const list = also && !photos.includes(also) ? [...photos, also] : photos;
const next: Record<string, string> = {};
for (const photo of photos) {
for (const photo of list) {
if (!photo.thumb) continue;
const had = made.current.get(photo.id);
if (had) next[photo.id] = had;
@@ -1318,7 +1350,7 @@ function useThumbUrls(photos: LibraryPhoto[]): Record<string, string> {
made.current.delete(id);
}
setUrls(next);
}, [photos]);
}, [photos, also]);
// The screen going away takes every URL with it.
useEffect(
() => () => {
@@ -1329,3 +1361,35 @@ function useThumbUrls(photos: LibraryPhoto[]): Record<string, string> {
);
return urls;
}
// Which tiles of the strip are drawn: the run under the viewport and `STRIP_KEEP`
// either side of it. Without this the strip builds a tile for every frame the open
// node holds before the reader has seen one — 400ms of DOM and a second of object
// URLs on a roll of twenty thousand — and the screen is dead for the whole of it.
// `on` is the strip being up: the element the window measures is the strip's own.
function useStripWindow(count: number, on: boolean): { ref: RefObject<HTMLDivElement>; from: number; to: number } {
const ref = useRef<HTMLDivElement>(null);
// The first window is a screenful: the strip is not in the DOM yet when this
// runs, so what it measures is the next paint's, and this is what the first
// paint draws.
const [win, setWin] = useState(() => ({ from: 0, to: Math.min(count, STRIP_KEEP * 2 + 24) }));
useEffect(() => {
if (!on) return;
const el = ref.current;
if (!el) return;
const measure = () => {
const from = Math.max(0, Math.floor(el.scrollLeft / TILE_STEP) - STRIP_KEEP);
const to = Math.min(count, Math.ceil((el.scrollLeft + el.clientWidth) / TILE_STEP) + STRIP_KEEP);
setWin((prev) => (prev.from === from && prev.to === to ? prev : { from, to }));
};
measure();
el.addEventListener('scroll', measure, { passive: true });
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => {
el.removeEventListener('scroll', measure);
ro.disconnect();
};
}, [count, on]);
return { ref, from: win.from, to: win.to };
}
+4
View File
@@ -999,6 +999,10 @@ input[type="range"] { width: 100%; accent-color: var(--accent); }
/* The album's strip: thumbnails in one row, scrolled sideways when they
overflow the column rather than wrapping into a wall of pictures. */
.adm-filmstrip { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 4px; }
/* A run of the strip that is not drawn — the tiles left and right of the window.
It is given the width they would have taken, and it must not shrink, or the
scrollbar would measure the window instead of the shelf. */
.adm-thumb-gap { flex: 0 0 auto; }
.adm-thumb {
flex: 0 0 132px;
margin: 0;