From 79b0d0db86dbf696aa3ab4296669656fe820e9d6 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Mon, 28 Sep 2026 20:28:00 +0700 Subject: [PATCH] web: keep reading a roll while the studio is up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The catalogue hands the visitor to the studio with a plain ``, 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. --- docker/frontend/scripts/scan-nav-check.mjs | 236 +++++++++++++++++++++ docker/frontend/src/Library.tsx | 89 ++++---- docker/frontend/src/engine/library.ts | 75 +++++++ docker/frontend/src/main.tsx | 71 +++++-- 4 files changed, 414 insertions(+), 57 deletions(-) create mode 100644 docker/frontend/scripts/scan-nav-check.mjs diff --git a/docker/frontend/scripts/scan-nav-check.mjs b/docker/frontend/scripts/scan-nav-check.mjs new file mode 100644 index 0000000..2dd498e --- /dev/null +++ b/docker/frontend/scripts/scan-nav-check.mjs @@ -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); diff --git a/docker/frontend/src/Library.tsx b/docker/frontend/src/Library.tsx index 036e312..bc030fc 100644 --- a/docker/frontend/src/Library.tsx +++ b/docker/frontend/src/Library.tsx @@ -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(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(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(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. */}
- - {progress ? ( - ) : null} @@ -427,7 +432,7 @@ export function Library() { {t('lib.hint')} - {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 }) : ''} {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 (