web: the session's photo waits for the account before it opens — a PRO clicking a RAW in the LIBRARY was read as a guest, asked to sign in, and the RAW never opened

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.
This commit is contained in:
2026-09-30 21:24:01 +07:00
parent 166a677590
commit 91aeacbe46
+32 -8
View File
@@ -545,6 +545,11 @@ export function Workspace() {
const [choosingExport, setChoosingExport] = useState(false);
const [savingPhoto, setSavingPhoto] = useState(false);
const [user, setUser] = useState<User | null>(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=<id>`) 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=<id>`) 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