perf: istemci istek cache'i (dedup+TTL) ve profil cache (Faz 5)

- lib/istek-cache: modül seviyesi in-flight dedup + 5dk TTL cache (SWR
  bağımlılığı olmadan); caller iptali paylaşılan fetch'i düşürmez
- /api/facetler'in 5 çağrı yeri (hero, cta, kapı, sihirbaz adımları, demo)
  ve /api/katalog-ara cache'e bağlandı — sihirbazda 2↔3 adım geçişi ve
  tekrarlanan aramalar artık ağa çıkmıyor; /api/soru GET dedup-only (ttl 0)
- katalog-arama: elle yukleniyor state'i kalktı, "cevaplanmamış sorgu"
  türetmesi + startTransition; rehber puanlaması Schwartzian; ⌘K listener'ı
  useEffectEvent ile tek sefer bağlanıyor
- sihirbaz-adimlar: il sort/filter useMemo (çip başına 81 eleman kopyalanıp
  sıralanıyordu, harita dizisi referansı sabitlendi)
- sohbet-client: stream chunk güncellemeleri startTransition'da — cevap
  akarken input yazımı bloklanmıyor
- sihirbaz-profil: modül cache + event/storage geçersizlemesi (tek "+"
  tıklamasında 3 localStorage okuma + JSON.parse vardı)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
bilalgursen
2026-08-08 14:11:05 +03:00
parent 90ac590737
commit 668568b5b5
9 changed files with 174 additions and 57 deletions

View File

@@ -1,8 +1,10 @@
"use client";
import {
startTransition,
useCallback,
useEffect,
useEffectEvent,
useMemo,
useRef,
useState,
@@ -14,6 +16,7 @@ import { AramaFunnelKarti } from "@/components/arama-funnel-karti";
import { RehberKapak } from "@/features/rehber/components/rehber-kapak";
import { UniLogo } from "@/components/uni-logo";
import { aramaNormalize, eslesmePuani } from "@/lib/arama";
import { onbellekliJsonGetir } from "@/lib/istek-cache";
import type { RehberAramaKaydi } from "@/lib/rehber";
import {
profilDuzenlemeyiAc,
@@ -114,7 +117,9 @@ export function KatalogArama({ rehberler }: { rehberler: RehberAramaKaydi[] }) {
}
const [sorgu, setSorgu] = useState("");
const [sonuclar, setSonuclar] = useState<AramaSonuclari>(BOS_SONUCLAR);
const [yukleniyor, setYukleniyor] = useState(false);
// Yükleniyor ayrı state değil türetme: "yazılan sorgu henüz cevaplanmadı".
// onChange'de elle iskelet açıp fetch finally'sinde kapatma dansı kalktı.
const [cevaplananSorgu, setCevaplananSorgu] = useState("");
// Kısayol etiketi (⌘/Ctrl) platforma bağlı sabit bir dış değer: effect'te
// setState yerine useSyncExternalStore — SSR'da ⌘ (eski varsayılan),
// hydration sonrası gerçek platform.
@@ -129,6 +134,7 @@ export function KatalogArama({ rehberler }: { rehberler: RehberAramaKaydi[] }) {
} | null>(null);
const temizSorgu = sorgu.trim();
const aramaAktif = temizSorgu.length >= 2;
const yukleniyor = aramaAktif && cevaplananSorgu !== temizSorgu;
const kenarBlurGuncelle = useCallback(() => {
const alan = kaydirmaRef.current;
@@ -145,16 +151,16 @@ export function KatalogArama({ rehberler }: { rehberler: RehberAramaKaydi[] }) {
const rehberSonuclari = useMemo(() => {
if (!aramaAktif) return [];
const normalSorgu = aramaNormalize(temizSorgu).slice(0, 80);
// Puan öğe başına BİR kez hesaplanır (Schwartzian) — sort comparator'ı
// her karşılaştırmada eslesmePuani->aramaNormalize çalıştırıyordu
return rehberler
.filter((yazi) =>
aramaNormalize(`${yazi.baslik} ${yazi.aciklama}`).includes(normalSorgu),
)
.sort(
(a, b) =>
eslesmePuani(a.baslik, normalSorgu) -
eslesmePuani(b.baslik, normalSorgu),
)
.slice(0, REHBER_SONUC_SINIRI);
.map((yazi) => ({ yazi, puan: eslesmePuani(yazi.baslik, normalSorgu) }))
.sort((a, b) => a.puan - b.puan)
.slice(0, REHBER_SONUC_SINIRI)
.map(({ yazi }) => yazi);
}, [aramaAktif, temizSorgu, rehberler]);
const sonucVar =
@@ -162,31 +168,39 @@ export function KatalogArama({ rehberler }: { rehberler: RehberAramaKaydi[] }) {
sonuclar.universiteler.length > 0 ||
rehberSonuclari.length > 0;
// Listener bir kez bağlanır; güncel `acik` useEffectEvent içinden okunur —
// modal her açılıp kapandığında remove/add döngüsü dönmesin
// (client-event-listeners)
const kisayolTetiklendi = useEffectEvent(() => modalDegisti(!acik));
useEffect(() => {
function kisayolDinle(event: KeyboardEvent) {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
event.preventDefault();
modalDegisti(!acik);
kisayolTetiklendi();
}
}
window.addEventListener("keydown", kisayolDinle);
return () => window.removeEventListener("keydown", kisayolDinle);
}, [acik]);
}, []);
useEffect(() => {
if (!acik || !aramaAktif) return;
const controller = new AbortController();
const zamanlayici = window.setTimeout(async () => {
setYukleniyor(true);
try {
const response = await fetch(
// Dedup+TTL cache: aynı sorgu ("psikoloji" yazıp silip tekrar yazan
// kullanıcı) ağa çıkmadan bellekten döner. İptal yalnızca bu
// bekleyişi düşürür; paylaşılan fetch sürer.
const veri = await onbellekliJsonGetir<AramaSonuclari>(
`/api/katalog-ara?q=${encodeURIComponent(temizSorgu)}`,
{ signal: controller.signal },
);
if (!response.ok) throw new Error("Arama başarısız");
const veri = (await response.json()) as AramaSonuclari;
setSonuclar(veri);
// Sonuç listesi acil değil — input yazımıyla yarışmasın
startTransition(() => {
setSonuclar(veri);
setCevaplananSorgu(temizSorgu);
});
// Olay burada değil kapanışta gönderilir; fetch her tuş vuruşunda
// (160ms debounce) tetikleniyor, aramayı oturum başına bir kez sayarız.
sonAramaRef.current = {
@@ -195,10 +209,11 @@ export function KatalogArama({ rehberler }: { rehberler: RehberAramaKaydi[] }) {
};
} catch (hata) {
if (!(hata instanceof DOMException && hata.name === "AbortError")) {
setSonuclar(BOS_SONUCLAR);
startTransition(() => {
setSonuclar(BOS_SONUCLAR);
setCevaplananSorgu(temizSorgu);
});
}
} finally {
if (!controller.signal.aborted) setYukleniyor(false);
}
}, 160);
@@ -231,7 +246,7 @@ export function KatalogArama({ rehberler }: { rehberler: RehberAramaKaydi[] }) {
if (yeniAcik) {
setSorgu("");
setSonuclar(BOS_SONUCLAR);
setYukleniyor(false);
setCevaplananSorgu("");
setKenarBlur({ ust: false, alt: false });
}
}
@@ -397,12 +412,7 @@ export function KatalogArama({ rehberler }: { rehberler: RehberAramaKaydi[] }) {
ref={inputRef}
type="text"
value={sorgu}
onChange={(event) => {
const yeniSorgu = event.target.value;
setSorgu(yeniSorgu);
setSonuclar(BOS_SONUCLAR);
setYukleniyor(yeniSorgu.trim().length >= 2);
}}
onChange={(event) => setSorgu(event.target.value)}
placeholder="Bölüm, üniversite veya rehber ara..."
aria-label="Bölüm, üniversite veya rehber ara"
autoComplete="off"