From e96f311651a27e78ce4b6ff2d39a91106a699592 Mon Sep 17 00:00:00 2001 From: Semih Yesilyurt Date: Mon, 25 May 2026 11:45:23 +0300 Subject: [PATCH 1/2] =?UTF-8?q?fix(web):=20reuse=20speculative=20pre-decod?= =?UTF-8?q?e=20on=20=C5=9Ease=20=C3=87=C3=B6z=20instead=20of=20decoding=20?= =?UTF-8?q?twice?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/web/src/routes/dashboard/search.tsx | 55 ++++++++++++++---------- 1 file changed, 32 insertions(+), 23 deletions(-) diff --git a/apps/web/src/routes/dashboard/search.tsx b/apps/web/src/routes/dashboard/search.tsx index 9f24632..cd1e0d6 100644 --- a/apps/web/src/routes/dashboard/search.tsx +++ b/apps/web/src/routes/dashboard/search.tsx @@ -61,7 +61,9 @@ function SearchPage() { const candidatesShownAtRef = useRef(null); const lastAttemptedVinRef = useRef(null); const attemptCountRef = useRef(0); - const preDecodedVinRef = useRef(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 } | null>(null); const [vin, setVin] = useState(""); const [loading, setLoading] = useState(false); const [error, setError] = useState(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("/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("/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("/vehicles/decode", { vin: cleanVin }); + } catch (reuseErr) { + if (reuse) { + data = await api.post("/vehicles/decode", { vin: cleanVin }); + } else { + throw reuseErr; + } + } const responseTimeMs = Math.round(performance.now() - decodeStart); if (data.candidates && Array.isArray(data.candidates)) { From 125c6fc43134a59fe76d866fff8e164f7b4e1a1e Mon Sep 17 00:00:00 2001 From: Semih Yesilyurt Date: Mon, 25 May 2026 11:57:12 +0300 Subject: [PATCH 2/2] feat(web): show vehicle-shaped shimmer while decoding instead of a frozen button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When Şase Çöz is clicked and the decode (reused pre-decode or fresh) is still in flight, the search form is replaced by a vehicle-page-shaped skeleton with a sweeping shimmer, so the wait reads as "the page is loading" rather than an unresponsive button. On success we navigate; candidates/errors fall back to the form. When the pre-decode already resolved, the skeleton is near-instant. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/web/src/routes/dashboard/search.tsx | 58 +++++++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/apps/web/src/routes/dashboard/search.tsx b/apps/web/src/routes/dashboard/search.tsx index cd1e0d6..313dd54 100644 --- a/apps/web/src/routes/dashboard/search.tsx +++ b/apps/web/src/routes/dashboard/search.tsx @@ -2,7 +2,7 @@ import { WelcomeOnboardingModal } from "@/components/onboarding/welcome-onboardi import { VehicleSelectModal } from "@/components/vehicles/vehicle-select-modal"; import { ApiError, api } from "@/lib/api-client"; import { startAction } from "@/lib/faro"; -import { KEYS_17 } from "@/lib/keys"; +import { KEYS_6, KEYS_17 } from "@/lib/keys"; import { capture } from "@/lib/posthog"; import { toast } from "@/lib/toast"; import { Badge, Button, Input, Separator } from "@sase/ui"; @@ -39,6 +39,50 @@ function sanitizeVin(raw: string): { cleaned: string; corrections: string[] } { return { cleaned, corrections }; } +/** A muted box with a sweeping light reflection (the shimmer keyframe in + * globals.css), used for the decode loading state. */ +function ShimmerBox({ className }: { className?: string }) { + return ( +
+
+
+ ); +} + +/** Shown the instant "Şase Çöz" is clicked while the decode is still in flight — + * a vehicle-page-shaped skeleton so the wait feels like the page is loading + * rather than an unresponsive button. */ +function DecodingSkeleton() { + return ( +
+ Araç bilgileri yükleniyor… + {/* Header: back + brand logo + 2-line title */} +
+ + +
+ + +
+
+ {/* Vehicle info card (collapsed) */} +
+ + +
+ {/* Categories grid */} +
+ +
+ {KEYS_6.map((k) => ( + + ))} +
+
+
+ ); +} + // ─── ROUTE ──────────────────────────────────────────────────────────────────── export const Route = createFileRoute("/dashboard/search")({ @@ -416,6 +460,18 @@ function SearchPage() { inputRef.current?.focus(); } + // While a decode is in flight (button clicked, result not back yet), swap the + // form for a vehicle-page-shaped shimmer so the click feels like it's loading + // the page — not a frozen button. On resolve we navigate (success), or fall + // back to the form (candidates/error). + if (loading) { + return ( +
+ +
+ ); + } + return (
{showWelcome && (