fix: ignore stale card preview saves and render checks

Queue card-preview saves so the last pick sticks, drop failures from
superseded picks or a previous account, and ignore a render check that
returns after its ModelRender is gone.
This commit is contained in:
Tautvydas Derzinskas committed 2026-10-20 11:54:19 +02:00
1 parent ddfe33a4bd
commit e76666e291
2 files changed
+23 -5

No files matched your search

+10 -1
View File
@@ -1,4 +1,4 @@
import { useEffect, useState, type ReactNode } from "react";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { keyframes } from "@emotion/react";
import Box from "@mui/material/Box";
import Skeleton from "@mui/material/Skeleton";
@@ -34,6 +34,14 @@ export default function ModelRender({ url, alt, onUnavailable, placeholder }: Pr
const [attempt, setAttempt] = useState(0);
const [waiting, setWaiting] = useState(false);
const [loaded, setLoaded] = useState(false);
// A check still out when the model changes or the card goes must not report on the next one.
const mounted = useRef(true);
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
};
}, []);
useEffect(() => {
if (attempt > RETRY_DELAYS_MS.length) onUnavailable();
@@ -89,6 +97,7 @@ export default function ModelRender({ url, alt, onUnavailable, placeholder }: Pr
.then((res) => res.json() as Promise<{ code?: string }>)
.catch(() => ({ code: undefined }))
.then(({ code }) => {
if (!mounted.current) return;
if (code && code !== "PREVIEW_GENERATING") return onUnavailable();
setAttempt((count) => count + 1);
setWaiting(true);
+13 -4
View File
@@ -7,6 +7,9 @@ let cached: CardPreview | undefined;
let inFlight: Promise<CardPreview> | null = null;
// Bumped on sign-in, so an answer still on its way for the previous account is dropped.
let account = 0;
// Saves go one at a time, so the last pick is the one that sticks.
let saving: Promise<unknown> = Promise.resolve();
let latestSave = 0;
const listeners = new Set<(value: CardPreview) => void>();
function load(): Promise<CardPreview> {
@@ -35,10 +38,11 @@ export function resetCardPreview() {
account++;
cached = undefined;
inFlight = null;
saving = Promise.resolve();
}
/** Null until the saved choice is known, so cards don't flash the wrong picture first. The setter shows the switch
* straight away, puts it back if the save fails, and rejects with the save's error. */
* straight away, puts it back if the latest save fails, and rejects with that save's error. */
export function useCardPreview(): [CardPreview | null, (preview: CardPreview) => Promise<void>] {
const [value, setValue] = useState<CardPreview | null>(cached ?? null);
@@ -56,12 +60,17 @@ export function useCardPreview(): [CardPreview | null, (preview: CardPreview) =>
const update = async (preview: CardPreview) => {
const previous = cached ?? "images";
const save = ++latestSave;
const forAccount = account;
publish(preview);
const request = saving.then(() => (forAccount === account ? settingsApi.updateCardPreview(preview) : undefined));
saving = request.catch(() => undefined);
try {
await settingsApi.updateCardPreview(preview);
await request;
} catch (err) {
// Only if nothing newer was picked meanwhile.
if (cached === preview) publish(previous);
// A newer pick, or another account, has taken over; this failure is no longer theirs to hear about.
if (save !== latestSave || forAccount !== account) return;
publish(previous);
throw err;
}
};