diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index 5671ce7..44e89da 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -746,9 +746,16 @@ export function Workspace() { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - // The photo half of the session, back out of IndexedDB. It waits for the - // account: which file may open depends on the tier, and the check reads it + // The photo half of the session, back out of IndexedDB. Which file may open + // depends on the tier — a RAW is a PRO develop and a catalogue frame is the + // same walk (`loadFile`) — but a JPEG of the visitor's own is nobody's + // question, and it is what the session almost always holds: it goes up on the + // first pass, with /me still in flight. The two that do ask wait for the + // account, and the effect runs again the moment it lands (91aeacb). const openLibraryPhotoRef = useRef<((id: string) => Promise) | null>(null); + // The session's JPEG is restored once and never twice: that second run would + // otherwise re-decode the frame the first pass already put on the stage. + const restoredRef = useRef(false); // When the stored session is empty but a RAW is still parked in OPFS, the RAW // is what "the last photo" means: develop it again rather than open on @@ -756,25 +763,33 @@ export function Workspace() { // both: the visitor just clicked it, and it is newer than anything the // session holds. useEffect(() => { - if (!authReady) return; let alive = true; const checkAndLoad = () => { const wanted = new URLSearchParams(window.location.search).get('lib'); - if (wanted && openLibraryPhotoRef.current) { - void openLibraryPhotoRef.current(wanted); - return true; - } - return false; + if (!wanted || !openLibraryPhotoRef.current) return false; + // Claimed either way: the frame the visitor just clicked wins over the + // session's own, so the session photo must not open under it while the + // account is still in flight. + if (authReady) void openLibraryPhotoRef.current(wanted); + return true; }; - if (!checkAndLoad()) { + if (!checkAndLoad() && !restoredRef.current) { loadPhoto() .then(async (photo) => { if (!alive) return; const show = (row: { name: string; bytes: Uint8Array }) => adoptPhoto(row.bytes, row.name, new Blob([row.bytes as BlobPart], { type: 'image/jpeg' }), true); - if (photo && !isRawName(photo.name)) return show(photo); + if (photo && !isRawName(photo.name)) { + restoredRef.current = true; + return show(photo); + } + // Only a RAW is left to restore, and only the account says whether the + // engine may develop it: read before /me has answered, a PRO's own + // parked RAW is a develop a guest is not offered. + if (!authReady) return; + restoredRef.current = true; const parked = await readRaw(); if (!alive) return; if (!parked) return photo ? show(photo) : undefined; diff --git a/docker/frontend/src/Library.tsx b/docker/frontend/src/Library.tsx index ba3d690..fbfad40 100644 --- a/docker/frontend/src/Library.tsx +++ b/docker/frontend/src/Library.tsx @@ -242,19 +242,35 @@ const why = (err: unknown) => { // from a folder that would be read for nothing is not the offer. The gate is // here rather than in the router so the scan never starts for a guest — the // catalogue component below is not mounted at all until the account is known. +// +// What the first paint reads is the answer the last visit got, the way the studio +// reads its own session back (see `loadState`): `/me` is a round trip, and a +// screen that draws nothing until it lands draws nothing for as long as the API +// is away — the frames the reader was working through are already on this disk +// and there is no reason to hold them behind it. The record only ever holds this +// visitor's own last answer, and an answer the API then contradicts puts the note +// back on screen the moment it arrives. +const ACCOUNT_KEY = 'recipescam.library.account'; export function Library() { const { t } = useI18n(); - const [state, setState] = useState<'loading' | 'guest' | 'ready'>('loading'); + const [state, setState] = useState<'loading' | 'guest' | 'ready'>( + () => (recall(ACCOUNT_KEY) as 'guest' | 'ready' | null) ?? 'loading' + ); useEffect(() => { let alive = true; api .me() .then((me) => { - if (alive) setState(me?.user ? 'ready' : 'guest'); + if (!alive) return; + const next = me?.user ? 'ready' : 'guest'; + keep(ACCOUNT_KEY, next); + setState(next); }) // No answer is treated like no account: the API being away is not a - // reason to open the shelves to whoever is on the page. + // reason to open the shelves to whoever is on the page. It is also not an + // answer, so it does not go in the record — the next visit asks again + // rather than reading the outage as this account's standing. .catch(() => { if (alive) setState('guest'); });