web: count a roll as it is read, and open a frame without losing the count

Two holes left by the last change. A frame is opened with `window.location.href`
rather than a link, so the studio was still handed a fresh page and the scan with
it — every route out of the catalogue now goes through `go()`, which pushes the
address instead of reloading while a scan is in flight. And a row counted what was
filed away rather than what had been read, so it sat at zero for the whole of a
first scan: the catalogue's frames only reach IndexedDB in one batch at the end.

`ScanProgress.counts` carries the frames the scan has reached, per row, off the
scan's own bookkeeping, and the column reads it while the scan runs — the row
under the reader's eye moves as the roll is read, and the toolbar's line stays the
whole picture. A re-read counts from the start, which is what it is doing.

The regression check grows the two: a frame opened mid-scan from the catalogue
(of a roll it already holds) has to stay in the page, and the row has to count.
This commit is contained in:
2026-09-28 20:39:51 +07:00
parent 79b0d0db86
commit 1cb2618fd7
5 changed files with 113 additions and 17 deletions
+56 -6
View File
@@ -90,8 +90,8 @@ const browser = await playwright.chromium.launch({
});
const context = await browser.newContext();
// Documents this tab has loaded, across navigations: 1 means the click stayed in
// the page, 2 means the page was thrown away and reloaded.
// Documents this tab has loaded, across navigations: the count only moves when a
// click threw the page away and started a new one.
await context.addInitScript(() => {
const n = Number(sessionStorage.getItem('docLoads') ?? '0') + 1;
sessionStorage.setItem('docLoads', String(n));
@@ -104,6 +104,10 @@ await context.addInitScript(
if (!bytes.has(name)) bytes.set(name, fetch(`${origin}/${name}`).then((r) => r.arrayBuffer()));
return bytes.get(name);
};
// A second scan of the same roll reads the same names again; dropping what the
// stand-in already holds makes it go back to the server for each of them, so
// a re-reading roll has the same minutes in it a first one does.
window.__forget = () => bytes.clear();
const fileHandle = (name) => {
const handle = { kind: 'file', name, __fake: true };
Object.defineProperty(handle, 'getFile', {
@@ -112,7 +116,11 @@ await context.addInitScript(
return handle;
};
const dirHandle = (label, node) => {
const handle = { kind: 'directory', name: label };
// `__fake` on a folder too: the catalogue reads its folders back out of the
// store after every remount, and a handle that does not say what it is comes
// back as a plain object with no `queryPermission` — a folder it cannot ask
// about is a folder it offers to reconnect.
const handle = { kind: 'directory', name: label, __fake: true };
Object.defineProperties(handle, {
values: {
value: () =>
@@ -186,6 +194,11 @@ const progressText = (timeout) =>
const docs = () => page.evaluate(() => Number(sessionStorage.getItem('docLoads') ?? '0'));
const tiles = () => page.$$eval('[data-key^="lib-thumb-"]', (els) => els.length);
// The count of documents standing when the page was last settled: every step that
// follows a link or a frame asserts against this, because a link followed with no
// scan in flight is *meant* to load the page again.
let seen = await docs();
const noReload = async () => (await docs()) === seen;
// Hand over to the studio while the roll is still being read, which is the whole
// point: part of it in, the rest still to come.
@@ -194,16 +207,17 @@ step('the roll is half read when the studio is opened', !!midway, String(midway)
await page.click('[data-key="lib-studio"]');
await page.waitForSelector('[data-key="nav-library"]', { timeout: 15_000 });
step('the handover stays in the page, it does not reload it', (await docs()) === 1, `${await docs()} document load(s)`);
step('the handover stays in the page, it does not reload it', await noReload(), `${await docs()} document load(s)`);
step('and it lands in the studio', new URL(page.url()).pathname === '/app', page.url());
// Back to the catalogue mid-scan: the screen that started the scan is gone and
// back, and the roll is still being read — the reading belongs to the tab.
await page.click('[data-key="nav-library"]');
await page.waitForSelector('[data-key="lib-frame"]', { timeout: 15_000 });
step('coming back stays in the page too', (await docs()) === 1, `${await docs()} document load(s)`);
step('coming back stays in the page too', await noReload(), `${await docs()} document load(s)`);
const progress = await progressText(15_000);
step('the catalogue comes back showing the scan it missed', !!progress, String(progress));
await page.waitForSelector('[data-key^="lib-node-"]', { timeout: 15_000 });
const tree = await page.$$eval('[data-key^="lib-node-"]', (els) => els.map((e) => e.dataset.key));
step('the tree is there, from the scan that outlived its screen', tree.includes('lib-node-SlowRoll'), JSON.stringify(tree));
@@ -217,7 +231,7 @@ for (let i = 0; i < 300 && indexed_to < TOTAL; i++) {
if (indexed_to < TOTAL) await sleep(200);
}
step('the whole roll is read with the studio up', indexed_to === TOTAL, `${indexed_to}/${TOTAL} indexed`);
step('and the studio is still the page it was', (await docs()) === 1, `${await docs()} document load(s)`);
step('and the studio is still the page it was', await noReload(), `${await docs()} document load(s)`);
await page.click('[data-key="nav-library"]');
await page.waitForFunction(
@@ -228,6 +242,42 @@ await page.waitForFunction(
step('every frame ends up as a thumbnail', (await tiles()) === TOTAL, `${await tiles()} tiles`);
const grown = await page.$$eval('[data-key^="lib-node-"]', (els) => els.map((e) => e.dataset.key));
step('the tree ends up holding the deepest folder of the roll', grown.includes('lib-node-SlowRoll/2026/04'), JSON.stringify(grown));
// Nothing was scanning at that last click, so that one did load the page again —
// which is the whole point of the guard. Everything below counts from here.
seen = await docs();
// Now the reader's own way into the studio, and the noisiest one for this page: a
// frame opened from a roll that is being read again. The rows are drawn from the
// catalogue it already has, so this is the one route out of here that the browser
// could take with no link anywhere in sight.
const rows = () => page.$eval('[data-key="lib-node-SlowRoll"] .adm-album-count', (el) => Number(el.textContent));
await page.evaluate(() => window.__forget());
await page.click('[data-key="lib-node-SlowRoll"]', { button: 'right' });
await page.waitForSelector('[data-key="lib-menu"]', { timeout: 10_000 });
const menu = await page.$$eval('[data-key="lib-menu"] button', (els) => els.map((b) => b.dataset.key));
const rescan = menu.find((k) => /^lib-(rescan|reconnect)-/.test(k));
step('the roll can be read again from its own menu', !!rescan, JSON.stringify(menu));
await page.click(`[data-key="${rescan}"]`);
await page.waitForSelector('[data-key="lib-stop"]', { timeout: 15_000 });
await sleep(DELAY);
const early = await rows();
await sleep(DELAY * 2);
const later = await rows();
step('the row counts the reading as it comes, not the catalogue standing still', later !== early, `${early} → ${later} of ${TOTAL}`);
step('the strip is still the catalogue the roll left behind', (await tiles()) === TOTAL, `${await tiles()} tiles`);
await page.click('[data-key="lib-open"]');
await page.waitForSelector('[data-key="nav-library"]', { timeout: 30_000 });
step('opening a frame stays in the page, it does not reload it', await noReload(), `${await docs()} document load(s)`);
// The studio takes the frame out of the address as soon as it has it, so the
// route is what is left to say the handover arrived.
step('and it lands in the studio', new URL(page.url()).pathname === '/app', page.url());
await page.click('[data-key="nav-library"]');
await page.waitForSelector('[data-key="lib-frame"]', { timeout: 15_000 });
step('and the scan it interrupted is still the scan in hand', await noReload(), `${await docs()} document load(s)`);
const steady = await progressText(15_000);
step('the catalogue is still reading the roll it was reading', !!steady, String(steady));
await browser.close();
app.kill('SIGTERM');
+14 -6
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useReducer, useRef, useState, type CSSProperties } from 'react';
import { useI18n } from './i18n/I18nProvider';
import { go } from './nav';
import {
canBrowseFolders,
ensurePermission,
@@ -272,7 +273,9 @@ export function Library() {
);
// The frame goes to the studio through the address bar: the studio owns the
// load (develop, EXIF, session) and this page owns no part of it.
// load (develop, EXIF, session) and this page owns no part of it. While a scan
// is running the address bar is the one thing that changes — `go` keeps the
// reading where it is instead of reloading the page out from under it.
const open = useCallback(async (photo: LibraryPhoto) => {
const row = await getPhoto(photo.id);
if (!row) return;
@@ -284,7 +287,7 @@ export function Library() {
setNote(t('lib.missing', { name: row.name }));
return;
}
window.location.href = `/app?lib=${encodeURIComponent(photo.id)}`;
go(`/app?lib=${encodeURIComponent(photo.id)}`);
}, [t]);
const rootName = node ? node.split('/')[0] : null;
@@ -297,9 +300,14 @@ export function Library() {
// The tree: one row per picked folder, then one per subfolder, indented by its
// depth — the folders the walk has named, whether or not a frame has been read
// out of them, each carrying how many frames sit at or under it.
// out of them, each carrying how many frames sit at or under it. While a scan is
// running the count is the scan's own, so a row says how far the reading has
// come; the frames only reach the catalogue in one batch at the end, and a row
// that stood still until then would say nothing at all.
const read = live?.progress.counts ?? null;
const nodes = useMemo<Node[]>(() => {
const hits = new Map<string, number>();
const counted = (key: string, hit: number | undefined) => read?.[key] ?? hit ?? 0;
const foldersUnder = new Map<string, Set<string>>();
// One path per folder between the picked one and `rel`, so a row is drawn for
// every folder on the way to a frame as well as for the frame's own.
@@ -322,7 +330,7 @@ export function Library() {
for (const dir of dirs) add(dir.folder, dir.rel, false);
const out: Node[] = [];
for (const folder of folders) {
out.push({ key: folder.name, label: folder.label || folder.name, depth: 0, count: hits.get(folder.name) ?? 0 });
out.push({ key: folder.name, label: folder.label || folder.name, depth: 0, count: counted(folder.name, hits.get(folder.name)) });
// Path order puts a folder before its own children, which is the order the
// indented rows have to come in.
for (const dir of [...(foldersUnder.get(folder.name) ?? [])].sort((a, b) => a.localeCompare(b))) {
@@ -331,12 +339,12 @@ export function Library() {
key: `${folder.name}/${dir}`,
label: parts[parts.length - 1],
depth: parts.length,
count: hits.get(`${folder.name}/${dir}`) ?? 0,
count: counted(`${folder.name}/${dir}`, hits.get(`${folder.name}/${dir}`)),
});
}
}
return out;
}, [dirs, folders, photos]);
}, [dirs, folders, photos, read]);
// A row with anything under it is the one that folds: its key is the prefix of
// its children's, in the path order the rows come in.
+25 -2
View File
@@ -253,6 +253,13 @@ export interface ScanProgress {
total: number;
done: number;
added: number;
// How many frames the scan has read out of each row of the tree, keyed the way
// the tree spells a row (`folder` for the picked folder, `folder/sub/dir` for
// the rest) and counted the way the rows count: a frame sits on its own row and
// on every row above it. A fresh object every frame, so a screen drawing the
// column sees a change; the frames themselves only reach the catalogue in one
// batch at the end, which would leave every row reading zero until then.
counts: Record<string, number>;
// Every folder the walk has been into so far, a fresh list each time: the
// column draws them while the frames under them are still being read. Not
// filed away — a folder name is worth nothing once the scan it came from ends.
@@ -284,7 +291,22 @@ export async function scanFolder(
// whole scan so a folder that has gone from the disk goes from the column too.
const names: string[] = [];
const found = new Set<string>();
const progress: ScanProgress = { folder: folder.name, total: 0, done: 0, added: 0, dirs: [] };
const progress: ScanProgress = { folder: folder.name, total: 0, done: 0, added: 0, dirs: [], counts: {} };
const counts: Record<string, number> = {};
// Count the frame the moment the scan gets to it, before it knows whether the
// frame is new: the rows say how far the reading has come, not what it kept.
const count = (rel: string) => {
const cut = rel.lastIndexOf('/');
counts[folder.name] = (counts[folder.name] ?? 0) + 1;
let path = '';
for (const part of (cut < 0 ? '' : rel.slice(0, cut)).split('/')) {
if (!part) continue;
path = path ? `${path}/${part}` : part;
const key = `${folder.name}/${path}`;
counts[key] = (counts[key] ?? 0) + 1;
}
progress.counts = { ...counts };
};
// The folders this scan has walked into, kept for the whole of it: the column
// is redrawn from this list, so it only ever grows.
const dirs: LibraryDir[] = [];
@@ -333,6 +355,7 @@ export async function scanFolder(
break;
}
progress.done++;
count(rel);
try {
const file = await handle.getFile();
const id = photoId(folder.name, rel);
@@ -423,7 +446,7 @@ export function startScan(folder: LibraryFolder): Promise<ScanProgress> {
if (live) return Promise.reject(new Error('a scan is already running'));
const session: ScanSession = {
folder: folder.name,
progress: { folder: folder.name, total: 0, done: 0, added: 0, dirs: [] },
progress: { folder: folder.name, total: 0, done: 0, added: 0, dirs: [], counts: {} },
stop: false,
jump: null,
};
+2 -3
View File
@@ -13,6 +13,7 @@ import { ProfilePage } from './ProfilePage';
import { PhotosPage } from './PhotosPage';
import { Library } from './Library';
import { scanSession } from './engine/library';
import { go } from './nav';
import { InstallPrompt } from './ui/InstallPrompt';
import { installTracking } from './track';
@@ -57,9 +58,7 @@ function Shell() {
if (url.origin !== window.location.origin || url.hash) return;
if (url.pathname + url.search === here) return;
e.preventDefault();
history.pushState(null, '', url.pathname + url.search);
setRoute(url.pathname + url.search);
window.scrollTo(0, 0);
go(url.pathname + url.search);
};
const onPop = () => setRoute(window.location.pathname + window.location.search);
document.addEventListener('click', onClick);
+16
View File
@@ -0,0 +1,16 @@
import { scanSession } from './engine/library';
// Internal navigation, in one place. The app has no router: a route is a page
// load, and that is fine for everything except a route change made while a scan
// is reading a roll, because the reading lives in this page's memory and a reload
// throws it away. So while a scan is in flight the route changes in place —
// push the address, tell whoever draws it — and with no scan running the browser
// navigates exactly as it always has.
export function go(url: string): void {
if (!scanSession()) {
window.location.href = url;
return;
}
history.pushState(null, '', url);
window.dispatchEvent(new PopStateEvent('popstate'));
}