web: keep reading a roll while the studio is up

The catalogue hands the visitor to the studio with a plain `<a href>`, and the
app has no router: following it threw the page away, so a scan that was halfway
through a roll died with it. A scan belongs to the tab, not to the screen that
started it.

The scan now lives outside the component (`scanSession`/`startScan`/`stopScan`,
watched by whoever is up), so the catalogue can unmount and come back to a
reading that never stopped; a screen that returns joins it and sees the toolbar
line, the stop button and the rows filling in. The internal links are taken over
for a history push *only while a scan is in flight* — everywhere else the
browser navigates exactly as before, so the landing-to-studio flow is untouched.

`scripts/scan-nav-check.mjs` is the regression: a roll read one frame at a time
off a slow server, handed to the studio mid-scan, back to the catalogue, and the
whole roll read to the end with the studio up, counting documents along the way.
This commit is contained in:
2026-09-28 20:28:00 +07:00
parent 3aedd67ca0
commit 79b0d0db86
4 changed files with 414 additions and 57 deletions
+236
View File
@@ -0,0 +1,236 @@
// TEMPORARY — the bug this proves: reading a roll used to die the moment the
// catalogue handed the visitor to the studio, because every internal link was a
// full page load and the scan lived in that page's memory. Run it, then delete
// it.
//
// node scripts/scan-nav-check.mjs
import { spawn } from 'node:child_process';
import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const FRONTEND = fileURLToPath(new URL('..', import.meta.url));
const SAMPLES = process.env.SAMPLE_DIR ?? path.resolve(FRONTEND, '..');
const APP_PORT = Number(process.env.APP_PORT ?? 4186);
const SAMPLE_PORT = Number(process.env.SAMPLE_PORT ?? 4320);
const DELAY = Number(process.env.SCAN_DELAY ?? 900);
const JPG = 'P1010256.JPG';
const RAW = 'P1010256.RW2';
// Twelve frames: four at the top, four a folder down, four two folders down.
const names = (prefix) => Array.from({ length: 4 }, (_, i) => `${prefix}${i + 1}.JPG`);
const ROLL = {
files: names('a'),
dirs: { '2026': { files: names('b'), dirs: { '04': { files: names('c'), dirs: {} } } } },
};
const TOTAL = 12;
let failures = 0;
function step(name, ok, detail = '') {
if (!ok) failures++;
console.log(`${ok ? 'ok ' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`);
return ok;
}
const playwright = await import(process.env.PLAYWRIGHT_CORE ?? 'playwright-core').catch(() => null);
if (!playwright) {
console.log('SKIP playwright-core is not installed');
process.exit(0);
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// One frame at a time, however many the page asks for at once: a roll that reads
// itself in parallel is over before a handover can be timed against it.
let turn = Promise.resolve();
const waitTurn = () => {
const mine = turn.then(() => sleep(DELAY));
turn = mine;
return mine;
};
const sampleServer = createServer(async (req, res) => {
const name = path.basename(decodeURIComponent((req.url ?? '/').split('?')[0]));
if (!/\.(jpg|rw2)$/i.test(name)) {
res.writeHead(404).end();
return;
}
const bytes = await readFile(path.join(SAMPLES, /\.rw2$/i.test(name) ? RAW : JPG));
await waitTurn();
res.writeHead(200, {
'Content-Type': 'application/octet-stream',
'Content-Length': bytes.length,
'Access-Control-Allow-Origin': '*',
});
res.end(bytes);
});
await new Promise((resolve) => sampleServer.listen(SAMPLE_PORT, '127.0.0.1', resolve));
const app = spawn('npx', ['vite', 'preview', '--port', String(APP_PORT), '--strictPort', '--host', '127.0.0.1'], {
cwd: FRONTEND,
stdio: 'ignore',
});
const base = `http://127.0.0.1:${APP_PORT}`;
let up = false;
for (let i = 0; i < 60 && !up; i++) {
up = await fetch(`${base}/library`).then((r) => r.ok).catch(() => false);
if (!up) await sleep(500);
}
if (!up) {
app.kill('SIGTERM');
sampleServer.close();
console.log(`FAIL no preview on ${base}`);
process.exit(1);
}
const browser = await playwright.chromium.launch({
executablePath: process.env.CHROME || undefined,
args: ['--no-sandbox', '--enable-unsafe-swiftshader', '--use-gl=angle', '--use-angle=swiftshader'],
});
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.
await context.addInitScript(() => {
const n = Number(sessionStorage.getItem('docLoads') ?? '0') + 1;
sessionStorage.setItem('docLoads', String(n));
});
await context.addInitScript(
({ origin, roll, folder }) => {
const bytes = new Map();
const load = async (name) => {
if (!bytes.has(name)) bytes.set(name, fetch(`${origin}/${name}`).then((r) => r.arrayBuffer()));
return bytes.get(name);
};
const fileHandle = (name) => {
const handle = { kind: 'file', name, __fake: true };
Object.defineProperty(handle, 'getFile', {
value: async () => new File([await load(name)], name, { type: 'image/jpeg' }),
});
return handle;
};
const dirHandle = (label, node) => {
const handle = { kind: 'directory', name: label };
Object.defineProperties(handle, {
values: {
value: () =>
[
...(node.files ?? []).map(fileHandle),
...Object.entries(node.dirs ?? {}).map(([name, kid]) => dirHandle(name, kid)),
][Symbol.iterator](),
},
queryPermission: { value: async () => 'granted' },
requestPermission: { value: async () => 'granted' },
});
return handle;
};
const root = () => dirHandle(folder, roll);
window.showDirectoryPicker = async () => root();
const revive = (row) => {
if (row?.handle?.__fake && !Object.getOwnPropertyDescriptor(row.handle, 'getFile')) {
row.handle = row.handle.kind === 'directory' ? root() : fileHandle(row.handle.name);
}
return row;
};
for (const method of ['get', 'getAll']) {
const original = IDBObjectStore.prototype[method];
IDBObjectStore.prototype[method] = function (...args) {
const request = original.apply(this, args);
const descriptor = Object.getOwnPropertyDescriptor(IDBRequest.prototype, 'result');
Object.defineProperty(request, 'result', {
configurable: true,
get() {
const value = descriptor.get.call(this);
return Array.isArray(value) ? value.map(revive) : revive(value);
},
});
return request;
};
}
},
{ origin: `http://127.0.0.1:${SAMPLE_PORT}`, roll: ROLL, folder: 'SlowRoll' }
);
const page = await context.newPage();
const indexed = () =>
page.evaluate(async () => {
const db = await new Promise((resolve, reject) => {
const req = indexedDB.open('recipescam-library');
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
return new Promise((resolve, reject) => {
const req = db.transaction('photos').objectStore('photos').count();
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
});
await page.goto(`${base}/library`);
await page.click('[data-key="lib-add"]');
await page.waitForSelector('[data-key="lib-stop"]', { timeout: 15_000 });
step('the scan is running', true);
// The rows only reach IndexedDB in one batch at the end of the scan, so the
// toolbar's own line is what says how far the reading is.
const progressText = (timeout) =>
page
.waitForFunction(() => document.querySelector('[data-key="lib-progress"]')?.textContent?.trim() || null, null, {
timeout,
polling: 100,
})
.then((h) => h.jsonValue())
.catch(() => null);
const docs = () => page.evaluate(() => Number(sessionStorage.getItem('docLoads') ?? '0'));
const tiles = () => page.$$eval('[data-key^="lib-thumb-"]', (els) => els.length);
// 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.
const midway = await progressText(15_000);
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('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)`);
const progress = await progressText(15_000);
step('the catalogue comes back showing the scan it missed', !!progress, String(progress));
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));
// Now leave it alone in the studio and let it finish: nothing up there reads the
// roll, so every frame that lands from here on landed for a screen that is gone.
await page.click('[data-key="lib-studio"]');
await page.waitForSelector('[data-key="nav-library"]', { timeout: 15_000 });
let indexed_to = 0;
for (let i = 0; i < 300 && indexed_to < TOTAL; i++) {
indexed_to = await indexed().catch(() => indexed_to);
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)`);
await page.click('[data-key="nav-library"]');
await page.waitForFunction(
(n) => document.querySelectorAll('[data-key^="lib-thumb-"]').length === n,
TOTAL,
{ timeout: 60_000, polling: 200 }
).catch(() => {});
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));
await browser.close();
app.kill('SIGTERM');
sampleServer.close();
console.log(failures ? `\n${failures} step(s) failed` : '\nall steps passed');
process.exit(failures ? 1 : 0);
+47 -42
View File
@@ -1,9 +1,10 @@
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
import { useCallback, useEffect, useMemo, useReducer, useRef, useState, type CSSProperties } from 'react';
import { useI18n } from './i18n/I18nProvider';
import {
canBrowseFolders,
ensurePermission,
getPhoto,
jumpScan,
listEditedIds,
listFolders,
listPhotos,
@@ -11,11 +12,13 @@ import {
readPhotoFile,
removeFolder,
renameFolder,
scanFolder,
scanSession,
startScan,
stopScan,
watchScan,
type LibraryDir,
type LibraryFolder,
type LibraryPhoto,
type ScanProgress,
} from './engine/library';
// The catalogue screen, laid out like the admin's picture manager: the tree of
@@ -91,18 +94,20 @@ export function Library() {
const saved = Number(remembered(COLUMN_KEY));
return saved >= COLUMN_MIN && saved <= COLUMN_MAX ? saved : COLUMN_DEFAULT;
});
const [progress, setProgress] = useState<ScanProgress | null>(null);
// The scan is the tab's, not this screen's — see `scanSession`. This screen is
// one of its watchers, and the counter is what tells React the session it is
// holding has moved on.
const live = scanSession();
const [, bump] = useReducer((n: number) => n + 1, 0);
// When the catalogue was last read back while a scan runs: the refresh reads the
// whole catalogue, so it keeps to one every few hundred milliseconds rather than
// one per frame.
const readAt = useRef(0);
const [note, setNote] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
// The folder menu: what a right click opens, and where. No folder means the
// right click landed on the empty part of the column, which is the way in for
// one more folder.
const [menu, setMenu] = useState<{ folder: LibraryFolder | null; x: number; y: number } | null>(null);
const stopRef = useRef(false);
// The folder the reader last clicked, as the walk spells it ('' for the picked
// folder, `2026/04/` under it): the scan in flight pulls it to the front of
// its queue, so the branch in front of the eye is read first.
const jumpRef = useRef<string | null>(null);
// Where the drag of the column divider started, and how wide the column was
// then — the pointer only ever reports a position, not a distance moved.
const grip = useRef<{ x: number; w: number } | null>(null);
@@ -117,6 +122,28 @@ export function Library() {
setEdited(edits);
}, []);
// A scan runs for as long as the roll takes, and this screen watches it rather
// than running it: the studio may be up instead, and the scan carries on. The
// names a pass has walked into are drawn as they arrive, a pass ahead of the
// frames under them; the catalogue behind the strip is read back on a timer.
useEffect(() => {
const watching = () => {
bump();
const progress = scanSession()?.progress;
if (!progress) return;
setDirs(progress.dirs);
if (progress.done === 0 || Date.now() - readAt.current > 700) {
readAt.current = Date.now();
void reload();
}
};
const unwatch = watchScan(watching);
// A scan already in flight when this screen opens is picked up where it is:
// the reader who left for the studio comes back to the same roll.
watching();
return unwatch;
}, [reload]);
// The catalogue outlives the tab; the permission to read those folders does
// not. Asking is a quiet prompt, and a refusal leaves the folder listed with
// a button that asks again on a click.
@@ -176,36 +203,14 @@ export function Library() {
const scan = useCallback(
async (folder: LibraryFolder) => {
stopRef.current = false;
setBusy(true);
setNote(null);
try {
// The column grows while the roll is read: the folders the walk has named
// are on screen a pass before the frames under them. A refresh reads the
// whole catalogue, so it keeps to one every few hundred milliseconds
// rather than one per frame.
let last = 0;
const result = await scanFolder(
folder,
(p) => {
setProgress(p);
setDirs(p.dirs);
if (p.done === 0 || Date.now() - last > 700) {
last = Date.now();
void reload();
}
},
() => stopRef.current,
() => jumpRef.current
);
const result = await startScan(folder);
setNote(t('lib.scanned', { folder: folder.name, added: result.added, total: result.total }));
setDirs(result.dirs);
await reload();
} catch {
setNote(t('lib.failed'));
} finally {
setProgress(null);
setBusy(false);
}
},
[reload, t]
@@ -415,11 +420,11 @@ export function Library() {
same row and ellipsises — the catalogue is the content, this bar
is furniture. */}
<div className="chip-row lib-bar">
<button type="button" className="btn primary" data-key="lib-add" disabled={busy} onClick={() => void add()}>
<button type="button" className="btn primary" data-key="lib-add" disabled={!!live} onClick={() => void add()}>
{t('lib.add')}
</button>
{progress ? (
<button type="button" className="btn" data-key="lib-stop" onClick={() => (stopRef.current = true)}>
{live ? (
<button type="button" className="btn" data-key="lib-stop" onClick={stopScan}>
{t('lib.stop')}
</button>
) : null}
@@ -427,7 +432,7 @@ export function Library() {
{t('lib.hint')}
</span>
<span className="hint" data-key="lib-progress">
{progress ? t('lib.scanning', { done: progress.done, total: progress.total, added: progress.added }) : ''}
{live ? t('lib.scanning', { done: live.progress.done, total: live.progress.total, added: live.progress.added }) : ''}
</span>
<span className="adm-spacer" />
{folders.length > 0 ? (
@@ -508,7 +513,7 @@ export function Library() {
const folder = folders.find((f) => f.name === n.key.split('/')[0]) ?? null;
// A roll being read says so on its own row: while a scan runs
// the toolbar's progress line is nowhere near the eye.
const scanning = !!folder && progress?.folder === folder.name;
const scanning = !!folder && live?.folder === folder.name;
const under = parents.has(n.key);
return (
<button
@@ -528,7 +533,7 @@ export function Library() {
setNode(n.key);
// The folder just clicked is the one a scan in flight
// reads next, whatever layer it sits in.
jumpRef.current = relOf(n.key);
jumpScan(relOf(n.key));
if (!under) return;
setFolded((prev) => {
const next = new Set(prev);
@@ -700,7 +705,7 @@ export function Library() {
type="button"
className="lib-menu-item"
data-key="lib-menu-add"
disabled={busy}
disabled={!!live}
onClick={() => {
setMenu(null);
void add();
@@ -725,7 +730,7 @@ export function Library() {
type="button"
className="lib-menu-item"
data-key={`lib-rescan-${menu.folder.name}`}
disabled={busy}
disabled={!!live}
onClick={() => {
setMenu(null);
void scan(menu.folder!);
@@ -750,7 +755,7 @@ export function Library() {
type="button"
className="lib-menu-item"
data-key={`lib-reconnect-${menu.folder.name}`}
disabled={busy}
disabled={!!live}
onClick={() => {
setMenu(null);
void reconnect(menu.folder!);
+75
View File
@@ -368,6 +368,81 @@ export async function scanFolder(
return progress;
}
// --- the scan in flight ----------------------------------------------------
// A scan belongs to the tab, not to the screen it was started from. A roll takes
// minutes, and the reader who started it has every reason to go to the studio and
// work on a frame while it runs — so the screen that started it may well be gone
// before the scan is. What that screen would have held lives here instead: one
// scan at a time, watched by whichever screen is up.
export interface ScanSession {
// The folder being read, by name.
folder: string;
progress: ScanProgress;
// What the reader has asked for since: stop, or read this folder next (the path
// as `rollWalk` spells it — `''` for the picked folder itself).
stop: boolean;
jump: string | null;
}
let live: ScanSession | null = null;
const watchers = new Set<() => void>();
const tell = () => {
for (const fn of watchers) fn();
};
// The scan in flight, or null. The same object for the whole of the scan, with
// `progress` replaced on every pass — watchers are told, so they re-read it.
export function scanSession(): ScanSession | null {
return live;
}
// Watch, and get the unsubscribe back: the shape an effect already has.
export function watchScan(fn: () => void): () => void {
watchers.add(fn);
return () => {
watchers.delete(fn);
};
}
export function stopScan(): void {
if (!live) return;
live.stop = true;
tell();
}
export function jumpScan(rel: string | null): void {
if (live) live.jump = rel;
}
// Start reading a roll. Only one at a time: the walk reads one frame at a time on
// this thread, so a second scan would only slow the first one down. The promise
// settles when the scan does — the caller that started it may be long gone.
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: [] },
stop: false,
jump: null,
};
live = session;
tell();
return scanFolder(
folder,
(p) => {
session.progress = p;
tell();
},
() => session.stop,
() => session.jump
).finally(() => {
live = null;
tell();
});
}
// --- reading ---------------------------------------------------------------
export async function listPhotos(folder?: string): Promise<LibraryPhoto[]> {
+56 -15
View File
@@ -1,3 +1,4 @@
import { useEffect, useState } from 'react';
import { createRoot } from 'react-dom/client';
import './styles/fonts.css';
import './styles/tokens.css';
@@ -11,6 +12,7 @@ import { Admin } from './Admin';
import { ProfilePage } from './ProfilePage';
import { PhotosPage } from './PhotosPage';
import { Library } from './Library';
import { scanSession } from './engine/library';
import { InstallPrompt } from './ui/InstallPrompt';
import { installTracking } from './track';
@@ -18,20 +20,59 @@ import { installTracking } from './track';
// screen, the member's own profile, the member's photo folder and the local
// catalogue of folders on the visitor's own disk. nginx serves index.html for
// all of them (SPA fallback), so this is a pathname check.
const path = window.location.pathname;
const page = path.startsWith('/app') ? (
<Workspace />
) : path.startsWith('/admin') ? (
<Admin />
) : path.startsWith('/photos') ? (
<PhotosPage />
) : path.startsWith('/library') ? (
<Library />
) : path.startsWith('/profile') ? (
<ProfilePage />
) : (
<Landing />
);
function pick(route: string) {
return route.startsWith('/app') ? (
<Workspace />
) : route.startsWith('/admin') ? (
<Admin />
) : route.startsWith('/photos') ? (
<PhotosPage />
) : route.startsWith('/library') ? (
<Library />
) : route.startsWith('/profile') ? (
<ProfilePage />
) : (
<Landing />
);
}
// Links are plain `<a href>`, so following one throws the page away — fine
// everywhere except while a roll is being read: that scan runs for minutes in
// this page's memory, and only the studio needs to be reachable from the
// catalogue. So the click is taken over *only* while a scan is in flight, and
// the browser keeps doing the navigating the rest of the time.
function Shell() {
const [route, setRoute] = useState(() => window.location.pathname + window.location.search);
useEffect(() => {
const onClick = (e: MouseEvent) => {
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
if (!scanSession()) return;
const target = e.target as Element | null;
const link = target?.closest?.('a');
if (!(link instanceof HTMLAnchorElement)) return;
if (link.target || link.hasAttribute('download')) return;
const url = new URL(link.href, window.location.href);
const here = window.location.pathname + window.location.search;
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);
};
const onPop = () => setRoute(window.location.pathname + window.location.search);
document.addEventListener('click', onClick);
window.addEventListener('popstate', onPop);
return () => {
document.removeEventListener('click', onClick);
window.removeEventListener('popstate', onPop);
};
}, []);
return pick(route);
}
const root = document.getElementById('root');
if (!root) throw new Error('#root missing');
@@ -45,7 +86,7 @@ installTracking();
createRoot(root).render(
<ThemeProvider>
<I18nProvider>
{page}
<Shell />
<InstallPrompt />
</I18nProvider>
</ThemeProvider>