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

62
src/lib/istek-cache.ts Normal file
View File

@@ -0,0 +1,62 @@
// İstemci tarafı GET istekleri için modül seviyesi in-flight dedup + TTL
// cache. SWR bağımlılığı yerine minimal el yapımı katman: /api/facetler beş
// farklı bileşenden aynı parametrelerle çağrılıyor, katalog araması aynı
// sorguyu tekrar tekrar atıyordu.
//
// - Aynı URL uçuştayken ikinci çağrı aynı promise'i bekler (dedup).
// - Başarılı yanıt ttlMs boyunca bellekte kalır; ttlMs: 0 = yalnız dedup
// (kullanıcıya özel, mutasyon sonrası tazelenmesi gereken uçlar için).
// - Paylaşılan fetch'e caller signal'ı GEÇİLMEZ: bir abonenin iptali
// diğerlerinin isteğini düşürmesin. İptal yalnızca o abonenin bekleyişini
// AbortError ile sonlandırır (katalog aramasının debounce/abort akışı
// davranış değiştirmeden buna bağlanır).
type Kayit = { zaman: number; veri: unknown };
const bellek = new Map<string, Kayit>();
const ucusta = new Map<string, Promise<unknown>>();
const MAX_KAYIT = 50;
const VARSAYILAN_TTL_MS = 5 * 60_000;
export async function onbellekliJsonGetir<T>(
url: string,
{
ttlMs = VARSAYILAN_TTL_MS,
signal,
}: { ttlMs?: number; signal?: AbortSignal } = {},
): Promise<T> {
const kayit = bellek.get(url);
if (kayit && ttlMs > 0 && Date.now() - kayit.zaman < ttlMs) {
return kayit.veri as T;
}
let soz = ucusta.get(url);
if (!soz) {
soz = fetch(url)
.then(async (res) => {
if (!res.ok) throw new Error(`istek-cache: ${res.status} (${url})`);
const veri: unknown = await res.json();
if (ttlMs > 0) {
bellek.set(url, { zaman: Date.now(), veri });
// Basit LRU kırpması: en eski kayıt düşer (insertion order)
if (bellek.size > MAX_KAYIT) {
const enEski = bellek.keys().next().value;
if (enEski !== undefined) bellek.delete(enEski);
}
}
return veri;
})
.finally(() => ucusta.delete(url));
ucusta.set(url, soz);
}
if (!signal) return soz as Promise<T>;
return new Promise<T>((cozul, reddet) => {
const iptalEt = () => reddet(new DOMException("Aborted", "AbortError"));
if (signal.aborted) return iptalEt();
signal.addEventListener("abort", iptalEt, { once: true });
(soz as Promise<T>).then(cozul, reddet).finally(() => {
signal.removeEventListener("abort", iptalEt);
});
});
}