From cda08c3908a2bef37c9041224f3f8e2b55ecd8fe Mon Sep 17 00:00:00 2001 From: 3dtours Date: Thu, 8 Oct 2026 09:23:23 +0700 Subject: [PATCH] fix(library): keep the shell the deploy replaced out of a failed navigation, and say when another tab holds the library open --- docker/frontend/public/sw.js | 36 +++++++++++++++++++++------ docker/frontend/src/Library.tsx | 16 ++++++++++++ docker/frontend/src/engine/library.ts | 13 ++++++++++ docker/frontend/src/i18n/en.ts | 2 ++ docker/frontend/src/i18n/vi.ts | 2 ++ 5 files changed, 62 insertions(+), 7 deletions(-) diff --git a/docker/frontend/public/sw.js b/docker/frontend/public/sw.js index 0075c57..be4eb50 100644 --- a/docker/frontend/public/sw.js +++ b/docker/frontend/public/sw.js @@ -37,9 +37,15 @@ const cachedFetch = (request) => return response; }); -// Offline, the studio the app is for; the landing page for a build without /app. -const offlineShell = async () => - (await caches.match('/app')) ?? (await caches.match('/')) ?? Response.error(); +// Offline, the route that was asked for if this browser has ever been on it, then +// the studio the app is for, then the landing page for a build without /app. The +// route first because that is the copy a visit keeps up to date: the other two may +// be shells of a build the deploy has replaced. +const offlineShell = async (request) => + (await caches.match(new Request(new URL(request.url).pathname))) ?? + (await caches.match('/app')) ?? + (await caches.match('/')) ?? + Response.error(); self.addEventListener('install', (event) => { // Best effort, one route at a time: a deploy caught mid-flight must not leave the @@ -73,12 +79,28 @@ self.addEventListener('fetch', (event) => { // `no-store` because a plain fetch is not the network: it may be answered by the // browser's own cache, which is the other place a stale shell hides. if (request.mode === 'navigate') { - event.respondWith(fetch(request, { cache: 'no-store' }).catch(offlineShell)); + event.respondWith( + fetch(request, { cache: 'no-store' }) + .then((response) => { + // The shell kept for offline is the one the server handed over last, not + // the one this worker installed with: a navigation that fails — a deploy + // recreating the container is the ordinary way one does — otherwise + // answers with the build of the first visit, `activate` never runs to + // replace it while this worker's bytes stay the same, and the browser + // runs an old build until a reload happens to land after the server is + // back. Stored under the route the navigation asked for, which is one of + // the three SHELL keys — a navigation request is not a key a cache takes. + if (response.ok) { + const copy = response.clone(); + const key = new Request(new URL(request.url).pathname); + caches.open(VERSION).then((cache) => cache.put(key, copy)).catch(() => {}); + } + return response; + }) + .catch(() => offlineShell(request)) + ); return; } - - // ponytail: a navigation is never written back, the precached entry is what - // answers offline. Add a put() here once a route must survive on its own. if (STATIC.test(url.pathname)) { event.respondWith(caches.match(request).then((hit) => hit ?? cachedFetch(request))); return; diff --git a/docker/frontend/src/Library.tsx b/docker/frontend/src/Library.tsx index 774be3d..875252c 100644 --- a/docker/frontend/src/Library.tsx +++ b/docker/frontend/src/Library.tsx @@ -20,6 +20,7 @@ import { listEditedIds, listFolders, normPath, + onLibraryBlocked, pickFolder, readPhotoFile, readPhotos, @@ -376,6 +377,15 @@ function Catalogue() { // the folders and cannot tell whether the remembered subfolder still exists. const [loaded, setLoaded] = useState(false); + // Whether another tab still holds the library database at the version it was + // opened with — a tab left open across a build. Nothing can be read until it + // goes, and the screen would otherwise sit blank with no reason on it. + const [libraryBlocked, setLibraryBlocked] = useState(false); + useEffect(() => { + onLibraryBlocked(setLibraryBlocked); + return () => onLibraryBlocked(null); + }, []); + // The folder the catalogue is copied into, and whether the browser is still // letting this page write to it — which it stops doing when the tab closes, so // the row below says so rather than letting a write fail quietly. The ref is @@ -1614,6 +1624,12 @@ function Catalogue() { ) : null} + {libraryBlocked ? ( +

+ {t('lib.blocked')} +

+ ) : null} + {/* The folders are on their way in for as long as the catalogue behind them is being read back, and a screen that says there is no folder before it has looked is a screen that says it every visit. */} diff --git a/docker/frontend/src/engine/library.ts b/docker/frontend/src/engine/library.ts index a3a2213..b25ec18 100644 --- a/docker/frontend/src/engine/library.ts +++ b/docker/frontend/src/engine/library.ts @@ -213,9 +213,21 @@ async function migrateLegacyDatabase(db: IDBDatabase): Promise { let dbPromise: Promise | null = null; +// One library database per browser, so every tab shares it — and a tab left open +// across a build holds it at the version it was opened with. The browser answers +// the newer tab's open with silence until that tab goes: no error, no rows, no +// note, a LIBRARY that has simply not loaded and never says why. The wait is +// reported instead, and it ends by itself when the other tab is closed or reloaded. +let blockedSink: ((blocked: boolean) => void) | null = null; + +export function onLibraryBlocked(fn: ((blocked: boolean) => void) | null): void { + blockedSink = fn; +} + function openDb(): Promise { dbPromise ??= new Promise((resolve, reject) => { const req = indexedDB.open(DB_NAME, DB_VERSION); + req.onblocked = () => blockedSink?.(true); req.onupgradeneeded = () => { const db = req.result; if (!db.objectStoreNames.contains(FOLDERS)) db.createObjectStore(FOLDERS, { keyPath: 'name' }); @@ -230,6 +242,7 @@ function openDb(): Promise { if (!db.objectStoreNames.contains(EDITS)) db.createObjectStore(EDITS, { keyPath: 'photoId' }); }; req.onsuccess = () => { + blockedSink?.(false); const db = req.result; void migrateLegacyDatabase(db).then(() => resolve(db)); }; diff --git a/docker/frontend/src/i18n/en.ts b/docker/frontend/src/i18n/en.ts index 8039f8a..c74429a 100644 --- a/docker/frontend/src/i18n/en.ts +++ b/docker/frontend/src/i18n/en.ts @@ -296,6 +296,8 @@ export const en: Dict = { 'lib.scanned': 'Scanned {folder}: {added}/{total} new frames.', 'lib.empty': 'Nothing in this folder yet.', 'lib.noFolders': 'No folder yet. Hit ADD FOLDER and pick a photo folder on this machine.', + 'lib.blocked': + 'Another tab of this app is holding the library open from an older build. Close that tab, or reload it, and this one picks the library up.', 'lib.missing': 'Could not open {name} — the file has been moved or deleted.', 'lib.failed': 'That folder could not be read.', 'lib.unsupported': diff --git a/docker/frontend/src/i18n/vi.ts b/docker/frontend/src/i18n/vi.ts index 1f93090..bafd521 100644 --- a/docker/frontend/src/i18n/vi.ts +++ b/docker/frontend/src/i18n/vi.ts @@ -309,6 +309,8 @@ export const vi = { 'lib.scanned': 'Đã quét {folder}: {added}/{total} ảnh mới.', 'lib.empty': 'Chưa có ảnh nào trong thư mục này.', 'lib.noFolders': 'Chưa có thư mục nào. Bấm THÊM THƯ MỤC để chọn một thư mục ảnh trên máy.', + 'lib.blocked': + 'Một tab khác của ứng dụng đang giữ thư viện ở bản cũ hơn. Hãy đóng tab đó, hoặc tải lại nó, để tab này mở được thư viện.', 'lib.missing': 'Không mở được {name} — tệp đã bị di chuyển hoặc xoá.', 'lib.failed': 'Không đọc được thư mục này.', 'lib.unsupported':