fix(library): land a reading's rows as they are read, and keep the copy the reader reads offline
- scan: rows go down on a clock as well as on a full batch. The reader's folder of 26 RAWs is under one batch of 50, so nothing of it reached the screen until the reading was over — a wall that stood empty for the whole scan and then filled in one go. Frames now reach the screen every 400ms while the roll is still being read, so the count climbs and the first tiles are drawn as the frames land. - tiles: a HEIC or a RAW has its tile written the first time it is drawn, so every later visit reads the tile back instead of the file. - resume: a reading a reload cut off is offered by the next visit with a chip instead of being run behind the reader's back. - offline listing: the desk's listing is kept whole in IndexedDB under `recipescam-offline`/`reads`, past the 200 000 characters localStorage held, so an offline visit opens on the listing it kept instead of on nothing. - offline writes: a vote or a view cast with no server is held and goes out when one answers again, with a bar that says how many are waiting. - sw: the shell precaches its own bundle, stylesheet and icon, read out of the document — a worker that precached only the document answered an offline launch with a page whose JavaScript the machine did not have. - checks in docker/frontend/scripts: tree-scan-check (counts while reading, resume after a reload, tiles of a long roll), offline-read-check, offline-queue-check.
This commit is contained in:
@@ -0,0 +1,234 @@
|
||||
// Whether the server can be reached, and what it last said.
|
||||
//
|
||||
// Two things the offline story needs and no more: a flag the screen can watch
|
||||
// (the notice in ui/OfflineBar.tsx), and the last good answer to each read, so a
|
||||
// reload with no server comes back on the data it was already showing instead of
|
||||
// an empty page.
|
||||
//
|
||||
// The flag is set by the API layer, the one place that finds out: a fetch that
|
||||
// never got an answer is a server that is gone, and the next one that answers is
|
||||
// the server back. `navigator.onLine` is the browser's guess and is wrong exactly
|
||||
// where it matters — a server down behind a working connection — so it only sets
|
||||
// the flag one way, and never clears it.
|
||||
//
|
||||
// The last good answers live in a store of their own in IndexedDB, keyed by the
|
||||
// route that gave them: the strip's listing is a few hundred kilobytes and
|
||||
// localStorage's few megabytes hold the theme, the language and the library's
|
||||
// view besides. They are this browser's, not this account's, so they are dropped
|
||||
// whenever the account changes (see `forgetStale`, called from the auth calls in
|
||||
// api.ts).
|
||||
//
|
||||
// The writes that found no server are held here too, in order, and sent the
|
||||
// moment one answers — same storage, same account, same clearing. See `hold`.
|
||||
import { keep, recall } from '../remember';
|
||||
|
||||
// The writes waiting for a server. A write the API layer carries is always a
|
||||
// JSON string (the two that carry bytes — a photo, an avatar — never reach here,
|
||||
// see `hold`), so this is a handful of small bodies in the same small
|
||||
// localStorage the theme and the language already live in. A queue long enough
|
||||
// to matter is a queue the visitor has walked away from, and a reload keeps it
|
||||
// anyway.
|
||||
const PENDING = 'rc.pending';
|
||||
// A read is kept whole, whatever its size: the strip's listing carries thousands
|
||||
// of rows and it is exactly the read an offline visit wants an answer to. The
|
||||
// ceiling is only there so a body that is not a listing at all — a few megabytes
|
||||
// of image someone sent through a JSON route — is not written into the store.
|
||||
const CAP = 2_000_000;
|
||||
|
||||
let reachable = true;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
const notify = () => {
|
||||
for (const listener of listeners) listener();
|
||||
};
|
||||
|
||||
function set(next: boolean): void {
|
||||
if (reachable === next) return;
|
||||
reachable = next;
|
||||
notify();
|
||||
}
|
||||
|
||||
// The shape `useEffect` already has: an unsubscribe.
|
||||
export function subscribe(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
export function isReachable(): boolean {
|
||||
return reachable;
|
||||
}
|
||||
|
||||
// A request that never got an answer.
|
||||
export function serverGone(): void {
|
||||
set(false);
|
||||
}
|
||||
|
||||
// Any answer at all — a 401 included: a server that refuses is a server that is
|
||||
// there, and what the screen shows for it is its own business.
|
||||
export function serverAnswered(): void {
|
||||
set(true);
|
||||
flush();
|
||||
}
|
||||
|
||||
// One held write: the route, the verb, and the JSON body `call()` was handed.
|
||||
// `body: null` is a write that carries none (`DELETE`).
|
||||
export interface Held {
|
||||
method: string;
|
||||
path: string;
|
||||
body: string | null;
|
||||
}
|
||||
|
||||
function queue(): Held[] {
|
||||
const raw = recall(PENDING);
|
||||
if (raw === null) return [];
|
||||
try {
|
||||
const list = JSON.parse(raw) as Held[];
|
||||
return Array.isArray(list) ? list : [];
|
||||
} catch {
|
||||
// A half-written queue is no queue.
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function setQueue(list: Held[]): void {
|
||||
keep(PENDING, JSON.stringify(list));
|
||||
notify();
|
||||
}
|
||||
|
||||
// How many writes the server has not seen yet — the count the bar shows.
|
||||
export function heldCount(): number {
|
||||
return queue().length;
|
||||
}
|
||||
|
||||
// Hold a write the server did not answer. The same route twice is the later
|
||||
// write in the earlier one's place: two edits of one recipe offline are one
|
||||
// recipe to save, not two.
|
||||
//
|
||||
// Only what the API layer carries is held, and a file is only ever carried
|
||||
// round it (a photo upload, an avatar) — those are not held at all: there is no
|
||||
// trustworthy place for a few megabytes in this browser, and the uploader's own
|
||||
// retry is the honest answer for a file.
|
||||
export function hold(path: string, method: string, body: string | null): void {
|
||||
const list = queue();
|
||||
const at = list.findIndex((item) => item.path === path && item.method === method);
|
||||
const one: Held = { method, path, body };
|
||||
if (at === -1) list.push(one);
|
||||
else list[at] = one;
|
||||
setQueue(list);
|
||||
}
|
||||
|
||||
let sending = false;
|
||||
|
||||
// A server answered: the writes it never got go now, oldest first. A refusal
|
||||
// (4xx) is that server's own answer, so the entry goes — asking the same
|
||||
// question forever is not a queue, it is a loop. No answer at all, or a server
|
||||
// that broke (5xx, 429), stops the run and leaves the rest for the next answer.
|
||||
export function flush(): void {
|
||||
if (sending || !reachable) return;
|
||||
const list = queue();
|
||||
if (list.length === 0) return;
|
||||
sending = true;
|
||||
void (async () => {
|
||||
for (const item of list) {
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`/api${item.path}`, {
|
||||
method: item.method,
|
||||
credentials: 'same-origin',
|
||||
headers: item.body === null ? undefined : { 'content-type': 'application/json' },
|
||||
body: item.body ?? undefined,
|
||||
});
|
||||
} catch {
|
||||
// Gone again mid-run: what is left stays right where it is, in order.
|
||||
serverGone();
|
||||
break;
|
||||
}
|
||||
if (res.status >= 500 || res.status === 429) break;
|
||||
setQueue(queue().filter((it) => !(it.path === item.path && it.method === item.method)));
|
||||
}
|
||||
sending = false;
|
||||
})();
|
||||
}
|
||||
|
||||
// The reads are a database of their own, not a store added to the library's: a
|
||||
// tab left open holds the library at the version it opened it with, and a new
|
||||
// store there would have every visit wait on that tab. Their own key is the
|
||||
// route, their value the JSON string it answered with.
|
||||
const DB_NAME = 'recipescam-offline';
|
||||
const READS = 'reads';
|
||||
|
||||
let dbPromise: Promise<IDBDatabase> | null = null;
|
||||
|
||||
function openDb(): Promise<IDBDatabase> {
|
||||
dbPromise ??= new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const req = indexedDB.open(DB_NAME, 1);
|
||||
req.onupgradeneeded = () => req.result.createObjectStore(READS);
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
return dbPromise;
|
||||
}
|
||||
|
||||
// One request per call, the shape the library's own `ask` has: a keyed get, put
|
||||
// or clear is one transaction and nothing more.
|
||||
function ask(mode: IDBTransactionMode, run: (s: IDBObjectStore) => IDBRequest): Promise<unknown> {
|
||||
return openDb().then(
|
||||
(db) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const req = run(db.transaction(READS, mode).objectStore(READS));
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// What this route answered the last time a server was there, or null. Answered
|
||||
// out of the store, so it is a promise where the old localStorage read was not;
|
||||
// the one caller already awaits.
|
||||
export async function stale<T>(path: string): Promise<T | null> {
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = await ask('readonly', (s) => s.get(path));
|
||||
} catch {
|
||||
// No store to read, no answer to give — the caller's own empty state.
|
||||
return null;
|
||||
}
|
||||
if (typeof raw !== 'string') return null;
|
||||
try {
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
// A half-written value is no value.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Written in the background: the answer on screen is not waiting on the disk.
|
||||
export function keepStale(path: string, body: unknown): void {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = JSON.stringify(body);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (raw.length > CAP) return;
|
||||
void ask('readwrite', (s) => s.put(raw, path)).catch(() => {});
|
||||
}
|
||||
|
||||
// The account changed: the answers held are the last account's, and the next one
|
||||
// must not be shown them. Neither must it send its writes under the new name —
|
||||
// they were made by whoever was signed in when they landed.
|
||||
export function forgetStale(): void {
|
||||
void ask('readwrite', (s) => s.clear()).catch(() => {});
|
||||
try {
|
||||
localStorage.removeItem(PENDING);
|
||||
} catch {
|
||||
// Private mode: there was nothing to drop.
|
||||
}
|
||||
notify();
|
||||
}
|
||||
|
||||
// The browser going offline is the flag arriving early, and no more: it is never
|
||||
// used to clear it, because a connection says nothing about the server behind it.
|
||||
window.addEventListener('offline', serverGone);
|
||||
Reference in New Issue
Block a user