fix(web): reuse speculative pre-decode on Şase Çöz instead of decoding twice

The 17-char speculative pre-decode fired a POST /vehicles/decode and discarded
the result, then the button click fired a second identical decode — two
requests per VIN. Keep the pre-decode promise and have runDecode reuse it on the
first attempt (awaiting it if still in flight), so the click navigates off the
already-running decode. Retries and the sub-350ms "clicked before warm" case
fall back to a fresh decode; a failed warm retries once.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-25 11:45:23 +03:00
parent df85ebad83
commit e96f311651

View File

@@ -61,7 +61,9 @@ function SearchPage() {
const candidatesShownAtRef = useRef<number | null>(null);
const lastAttemptedVinRef = useRef<string | null>(null);
const attemptCountRef = useRef<number>(0);
const preDecodedVinRef = useRef<string | null>(null);
// Holds the in-flight/finished speculative decode so the button can reuse it
// instead of firing a second identical request.
const preDecodeRef = useRef<{ vin: string; promise: Promise<any> } | null>(null);
const [vin, setVin] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -149,33 +151,23 @@ function SearchPage() {
}, [vin]);
// ─── Speculative pre-decode (the moment the VIN is valid) ──────────────────
// Warm the full decode in the background before the user clicks "Şase Çöz", so
// the button-click decode hits the backend lock / 24h positive cache and returns
// near-instantly — the dealer at the counter loses no time. Fire-and-forget: no
// state, no analytics, no navigation (the button path owns all of that). Deduped
// per VIN, debounced + aborted so typing/paste-then-edit doesn't spam decodes.
// Decode the VIN in the background before the user clicks "Şase Çöz" and KEEP
// the result. The button click reuses this promise (see runDecode) rather than
// firing a second identical decode — one request, not two. Deduped per VIN and
// debounced so typing/paste-then-edit doesn't spam decodes.
useEffect(() => {
const cleanVin = vin.toUpperCase().trim();
if (!isValidVin(cleanVin) || preDecodedVinRef.current === cleanVin) return;
if (!isValidVin(cleanVin) || preDecodeRef.current?.vin === cleanVin) return;
const controller = new AbortController();
const timer = window.setTimeout(() => {
preDecodedVinRef.current = cleanVin;
// Warm the backend decode chain; the result is intentionally discarded.
fetch("/api/vehicles/decode", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ vin: cleanVin }),
signal: controller.signal,
}).catch(() => {
/* speculative warm — ignore result/errors; the button-click decode owns the outcome */
});
const promise = api.post<any>("/vehicles/decode", { vin: cleanVin });
// Swallow here only to avoid an unhandled-rejection warning; the consumer
// (runDecode) attaches its own handler and surfaces the error to the user.
promise.catch(() => {});
preDecodeRef.current = { vin: cleanVin, promise };
}, 350);
return () => {
window.clearTimeout(timer);
controller.abort();
};
return () => window.clearTimeout(timer);
}, [vin]);
// ─── Auto-focus ────────────────────────────────────────────────────────────
@@ -189,7 +181,24 @@ function SearchPage() {
setLoading(true);
const decodeStart = performance.now();
try {
const data = await api.post<any>("/vehicles/decode", { vin: cleanVin });
// Reuse the speculative pre-decode when it's for this exact VIN (first
// attempt only) so the click doesn't fire a second identical request.
// Retries always decode fresh. If the reused warm failed, fall back once.
const pre = preDecodeRef.current;
const reuse = attempt === 1 && pre && pre.vin === cleanVin ? pre : null;
if (reuse) preDecodeRef.current = null;
let data: any;
try {
data = reuse
? await reuse.promise
: await api.post<any>("/vehicles/decode", { vin: cleanVin });
} catch (reuseErr) {
if (reuse) {
data = await api.post<any>("/vehicles/decode", { vin: cleanVin });
} else {
throw reuseErr;
}
}
const responseTimeMs = Math.round(performance.now() - decodeStart);
if (data.candidates && Array.isArray(data.candidates)) {