From 91aeacbe460440da57c11edbd1f71d497e70562f Mon Sep 17 00:00:00 2001 From: 3dtours Date: Wed, 30 Sep 2026 21:24:01 +0700 Subject: [PATCH] =?UTF-8?q?web:=20the=20session's=20photo=20waits=20for=20?= =?UTF-8?q?the=20account=20before=20it=20opens=20=E2=80=94=20a=20PRO=20cli?= =?UTF-8?q?cking=20a=20RAW=20in=20the=20LIBRARY=20was=20read=20as=20a=20gu?= =?UTF-8?q?est,=20asked=20to=20sign=20in,=20and=20the=20RAW=20never=20open?= =?UTF-8?q?ed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both halves of the boot ran in one effect: `api.me()` and the photo handover, with the handover not waiting for the answer. Which file may open is a tier question — `loadFile` turns a RAW away unless the account is PRO — and the tier came from the render that effect closed over, which was the FIRST one, where `user` is still null. So a PRO's own RAW was read as a guest's, `promptPro` raised the sign-in modal, and because the handover had already cleared `?lib=` out of the URL with `history.replaceState`, there was nothing left to retry: signing in landed on an empty workspace, with the frame the visitor clicked sitting in the library behind it. The account and a new `authReady` flag now land in ONE batch, and the handover is an effect of its own that returns until the flag is set. Both the batch and the wait matter: one setState per render is what lets the handover effect see a render that already carries `user`, and the flag is what says so. Checked against a stubbed `api.me()` answering after 800ms (scratchpad bug1-probe.mjs, a PRO opening `/app?lib=probe-frame`): on the old bundle the `?lib=` is gone at the first sample, 765ms BEFORE the answer, and the session photo opens on the wrong tier; here it survives until 193ms AFTER the answer. The catalogue itself cannot be stood up in a check — its frames are real FileSystemFileHandles in IndexedDB — so the RAW actually opening is a manual step on a real catalogue. Skipped: a GUEST who clicks a RAW still loses it across the sign-in — the handover has run by then and the `?lib=` is gone. Add when someone reports it; the tier that can open a RAW is the operator's own account, which is the case this fixes. --- docker/frontend/src/App.tsx | 40 +++++++++++++++++++++++++++++-------- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index 75e9b17..2307da0 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -545,6 +545,11 @@ export function Workspace() { const [choosingExport, setChoosingExport] = useState(false); const [savingPhoto, setSavingPhoto] = useState(false); const [user, setUser] = useState(null); + // `api.me()` has answered — the account is known, guest or not. Opening a + // photo asks a tier question (see `loadFile`), so the session's photo is + // restored only once this is true; before that `user` is null and every + // account reads as a guest. + const [authReady, setAuthReady] = useState(false); // Three tiers, one account each (see config/tiers). Signed in is the basic // tier: recipes, the photo folder, a clean export of the photo it is editing. // PRO is the box the operator ticks in the admin users table — HSL, TOOLS, @@ -665,13 +670,32 @@ export function Workspace() { .catch((err) => alive && setError(String(err))); api .me() - .then((r) => alive && setUser(r.user)) - .catch(() => undefined); // signed out is a valid state — the demo needs no account - // ...and the photo half of the session, back out of IndexedDB. When that 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 nothing. - // A frame handed over by the catalogue (`/app?lib=`) wins over both: the - // visitor just clicked it, and it is newer than anything the session holds. + .then((r) => { + if (!alive) return; + // The account and the flag that says the account is known land in ONE + // batch: the handover effect below then sees a render that already + // carries `user`, so a PRO opening a RAW is not read as a guest. + setUser(r.user); + setAuthReady(true); + }) + .catch(() => alive && setAuthReady(true)); // signed out is a valid state — the demo needs no account + return () => { + alive = false; + }; + // 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 + // from the render — run it too early and a PRO's own RAW is turned away. + // 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 + // nothing. A frame handed over by the catalogue (`/app?lib=`) wins over + // both: the visitor just clicked it, and it is newer than anything the + // session holds. + useEffect(() => { + if (!authReady) return; + let alive = true; const wanted = new URLSearchParams(window.location.search).get('lib'); if (wanted) openLibraryPhoto(wanted); else @@ -691,7 +715,7 @@ export function Workspace() { alive = false; }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [authReady]); useEffect(() => { // The account's own two listings: the API answers a guest with a 401, and