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 ref keeps it to one redemption per token: `identity` is no longer in
// the dependency list precisely because redeeming sets it. // the dependency list precisely because redeeming sets it.
const redeemedInviteRef = useRef<string | null>(null); 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(() => { useEffect(() => {
if (identityMode !== 'guest') return; if (identityMode !== 'guest') return;
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
@@ -171,7 +176,7 @@ export const GuestIdentityProvider: React.FC<GuestIdentityProviderProps> = ({
if (!inviteToken || redeemedInviteRef.current === inviteToken) return; if (!inviteToken || redeemedInviteRef.current === inviteToken) return;
redeemedInviteRef.current = inviteToken; redeemedInviteRef.current = inviteToken;
(async () => { invitePromiseRef.current = (async () => {
try { try {
const response = await guestsService.redeemInvite(slug, inviteToken); const response = await guestsService.redeemInvite(slug, inviteToken);
storeGuestIdentity(slug, response.guest, response.token); 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. // Silently fail invalid invites; user will fall back to normal prompt.
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.warn('Failed to redeem invite token', error); console.warn('Failed to redeem invite token', error);
} finally {
invitePromiseRef.current = null;
} }
})(); })();
}, [slug, identityMode]); }, [slug, identityMode]);
@@ -255,7 +262,7 @@ export const GuestIdentityProvider: React.FC<GuestIdentityProviderProps> = ({
setIdentity(null); setIdentity(null);
}, [slug]); }, [slug]);
const ensureIdentity = useCallback((): Promise<GuestIdentity> => { const ensureIdentity = useCallback(async (): Promise<GuestIdentity> => {
if (identityMode !== 'guest') { if (identityMode !== 'guest') {
// In simple mode, there is no per-person identity. Return a synthetic // In simple mode, there is no per-person identity. Return a synthetic
// "null" identity that callers will ignore. // "null" identity that callers will ignore.
@@ -266,14 +273,26 @@ export const GuestIdentityProvider: React.FC<GuestIdentityProviderProps> = ({
identifier: '', identifier: '',
} as GuestIdentity); } 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) => { return new Promise((resolve, reject) => {
pendingResolvers.current.push(resolve); pendingResolvers.current.push(resolve);
pendingRejecters.current.push(reject); pendingRejecters.current.push(reject);
setPromptOpen(true); setPromptOpen(true);
}); });
}, [identityMode, identity]); }, [identityMode, identity, slug]);
const isRequired = identityMode === 'guest' && !identity; const isRequired = identityMode === 'guest' && !identity;