fix(guests): don't answer feedback with a stale identity mid-invite

Last open finding from codex round 3 on #1268.

Invite redemption is async and the gallery stays interactive while it runs, so
a like clicked in that window resolved against the persisted identity and was
filed under the wrong guest permanently. ensureIdentity() now waits on the
in-flight redemption and re-reads the result before falling back to the stored
identity or the prompt.
This commit is contained in:
Paul Nothaft
2026-09-02 10:22:55 +02:00
parent e9babf65e7
commit 7d51aa3db9
+23 -4
View File
@@ -164,6 +164,11 @@ export const GuestIdentityProvider: React.FC<GuestIdentityProviderProps> = ({
// The ref keeps it to one redemption per token: `identity` is no longer in
// the dependency list precisely because redeeming sets it.
const redeemedInviteRef = useRef<string | null>(null);
// Redemption is async, and the gallery stays interactive while it runs. A
// like clicked in that window would otherwise resolve against the persisted
// identity and be filed under the wrong guest, permanently. ensureIdentity()
// waits on this instead.
const invitePromiseRef = useRef<Promise<void> | null>(null);
useEffect(() => {
if (identityMode !== 'guest') return;
const params = new URLSearchParams(window.location.search);
@@ -171,7 +176,7 @@ export const GuestIdentityProvider: React.FC<GuestIdentityProviderProps> = ({
if (!inviteToken || redeemedInviteRef.current === inviteToken) return;
redeemedInviteRef.current = inviteToken;
(async () => {
invitePromiseRef.current = (async () => {
try {
const response = await guestsService.redeemInvite(slug, inviteToken);
storeGuestIdentity(slug, response.guest, response.token);
@@ -185,6 +190,8 @@ export const GuestIdentityProvider: React.FC<GuestIdentityProviderProps> = ({
// Silently fail invalid invites; user will fall back to normal prompt.
// eslint-disable-next-line no-console
console.warn('Failed to redeem invite token', error);
} finally {
invitePromiseRef.current = null;
}
})();
}, [slug, identityMode]);
@@ -255,7 +262,7 @@ export const GuestIdentityProvider: React.FC<GuestIdentityProviderProps> = ({
setIdentity(null);
}, [slug]);
const ensureIdentity = useCallback((): Promise<GuestIdentity> => {
const ensureIdentity = useCallback(async (): Promise<GuestIdentity> => {
if (identityMode !== 'guest') {
// In simple mode, there is no per-person identity. Return a synthetic
// "null" identity that callers will ignore.
@@ -266,14 +273,26 @@ export const GuestIdentityProvider: React.FC<GuestIdentityProviderProps> = ({
identifier: '',
} as GuestIdentity);
}
if (identity) return Promise.resolve(identity);
// An invite naming this visitor is mid-flight: wait for it rather than
// answering with the identity the browser happened to be holding.
if (invitePromiseRef.current) {
try {
await invitePromiseRef.current;
} catch {
// Redemption failed — fall through to the stored identity / prompt.
}
const redeemed = getGuestIdentity(slug);
if (redeemed) return redeemed;
}
if (identity) return identity;
return new Promise((resolve, reject) => {
pendingResolvers.current.push(resolve);
pendingRejecters.current.push(reject);
setPromptOpen(true);
});
}, [identityMode, identity]);
}, [identityMode, identity, slug]);
const isRequired = identityMode === 'guest' && !identity;