fix(liste): üretim sonrası ekran listeye kesin geçer

replaceState + router.refresh() ikilisinde Next yönlendirici kuyruğu refresh'i
hiç çalıştırmıyordu (ekran 'Listen hazırlanıyor'da kalıyor, yenileyince geliyordu).
Tek router.replace; 6 sn emniyet supabı (tam yükleme); 90 sn'de 'hâlâ hazırlanıyor'.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
bilalgursen
2026-09-21 23:34:52 +03:00
parent c2ec6d3102
commit ea3310ac5b

View File

@@ -10,6 +10,17 @@
// (bkz. tercih-farki.ts). Tekrar üretim riski yok: rapor varken bu bileşen // (bkz. tercih-farki.ts). Tekrar üretim riski yok: rapor varken bu bileşen
// hiç render edilmez (bkz. listem-icerik.tsx), yani ?uret=1 ile dönmek ya da // hiç render edilmez (bkz. listem-icerik.tsx), yani ?uret=1 ile dönmek ya da
// sayfayı yenilemek ikinci bir harcama başlatmaz. // sayfayı yenilemek ikinci bir harcama başlatmaz.
//
// Başarıdan sonra listeye geçiş (21 Eyl 2026 "hazırlanıyor'da kalıyor" hatası):
// eskiden `history.replaceState` + `router.refresh()` art arda çağrılıyordu.
// Server action'ın `await`i, Next'in aksiyon kuyruğu o action'ı henüz
// "bekleyen" sayarken devam ediyor; replaceState'in ürettiği RESTORE öne
// geçip action'ı "discarded" işaretliyor ama kuyruğun `last` işaretçisini
// güncellemiyor, ardından gelen REFRESH o atılmış düğümün arkasına bağlanıp
// HİÇ çalışmıyordu (bkz. next/dist/client/components/app-router-instance.js,
// dispatchAction). Sonuç: URL /listem oluyor, ekran iskelette kalıyordu.
// Kural: action cevabının hemen ardından yönlendiriciye TEK aksiyon gönder
// (router.replace); ikinci aksiyon (refresh) ancak o gezinme bitince.
import { useEffect, useRef, useState, type ReactNode } from "react"; import { useEffect, useRef, useState, type ReactNode } from "react";
import Link from "next/link"; import Link from "next/link";
@@ -17,7 +28,7 @@ import { useRouter } from "next/navigation";
import { ArrowRight, Lock, RefreshCcw } from "lucide-react"; import { ArrowRight, Lock, RefreshCcw } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { listeOlustur } from "@/features/rapor/rapor-actions"; import { listeOlustur, type ListeSonuc } from "@/features/rapor/rapor-actions";
import { olay, siraKovasi } from "@/lib/analitik"; import { olay, siraKovasi } from "@/lib/analitik";
import { sonucHref, type TercihProfili } from "@/features/sihirbaz/sihirbaz-sabitler"; import { sonucHref, type TercihProfili } from "@/features/sihirbaz/sihirbaz-sabitler";
import { tercihProfiliOku } from "@/features/sihirbaz/sihirbaz-profil"; import { tercihProfiliOku } from "@/features/sihirbaz/sihirbaz-profil";
@@ -25,8 +36,20 @@ import { Message } from "@/components/ui/message";
import { DanismanAvatar, DanismanBaslik } from "./sohbet-client"; import { DanismanAvatar, DanismanBaslik } from "./sohbet-client";
import { useUretimMesaji } from "./uretim-mesaji"; import { useUretimMesaji } from "./uretim-mesaji";
// Başarıdan sonra yönlendiricinin listeye geçmesi için tanınan süre; dolduğunda
// bileşen hâlâ ekrandaysa tam sayfa yüklemeyle geçilir (emniyet supabı).
const GECIS_SINIRI_MS = 6000;
// Tek Yapay Zeka çağrısının sunucu sınırı 60 sn (bkz. lib/ai/cagri.ts), tipik
// üretim ~20 sn. Bu eşikten sonra kullanıcıya "hâlâ hazırlanıyor" denir; istek
// kesilmez, cevap gelirse akış normal devam eder.
const UZUN_SURE_MS = 90_000;
type Durum = type Durum =
| { ad: "uretiliyor" } | { ad: "uretiliyor" }
// İstek hâlâ yolda ama beklenenden uzun sürdü: iskelet + bilgi notu
| { ad: "uzun-surdu" }
// Liste kuruldu; yönlendiricinin /listem'e geçmesi bekleniyor
| { ad: "geciliyor" }
| { ad: "kredi-yetersiz" } | { ad: "kredi-yetersiz" }
| { ad: "hata"; mesaj: string } | { ad: "hata"; mesaj: string }
| { ad: "profil-yok" }; | { ad: "profil-yok" };
@@ -57,45 +80,85 @@ export function ListeUretici({
if (!requestIdRef.current) requestIdRef.current = crypto.randomUUID(); if (!requestIdRef.current) requestIdRef.current = crypto.randomUUID();
setDurum({ ad: "uretiliyor" }); setDurum({ ad: "uretiliyor" });
// try yalnızca isteği sarar: cevap geldikten SONRAKİ bir istisna "hata"
// ekranına düşerse "Tekrar dene" taze requestId ile ikinci kez kredi
// harcatırdı. Cevap kaybolursa requestId korunur → retry idempotent.
let sonuc: ListeSonuc;
try { try {
const sonuc = await listeOlustur({ sonuc = await listeOlustur({
sira: profil.sira, sira: profil.sira,
tur: profil.tur, tur: profil.tur,
secimler: profil.secimler, secimler: profil.secimler,
requestId: requestIdRef.current, requestId: requestIdRef.current,
}); });
if (sonuc.ok) {
requestIdRef.current = null;
olay("liste_olusturuldu", {
tur: profil.tur,
sira_kovasi: siraKovasi(profil.sira),
});
// ?uret=1 URL'de kalmasın: geri dönüşte anlamsız bayrak taşınmasın.
// refresh sunucudaki raporu (ve header'daki kredi pill'ini) ekrana
// getirir.
window.history.replaceState(null, "", "/listem");
router.refresh();
return;
}
requestIdRef.current = null;
if (sonuc.code === "KREDI" || sonuc.code === "PAKET") {
olay("kredi_bitti");
setDurum({ ad: "kredi-yetersiz" });
} else if (sonuc.code === "AUTH") {
router.push(`/giris?callback=${encodeURIComponent("/listem?uret=1")}`);
} else {
olay("liste_uretim_hata", { kod: sonuc.code });
setDurum({ ad: "hata", mesaj: sonuc.error });
}
} catch { } catch {
olay("liste_uretim_hata", { kod: "ISTISNA" }); olay("liste_uretim_hata", { kod: "ISTISNA" });
setDurum({ setDurum({
ad: "hata", ad: "hata",
mesaj: "Beklenmeyen bir hata oluştu, tekrar dener misin?", mesaj: "Beklenmeyen bir hata oluştu, tekrar dener misin?",
}); });
return;
}
requestIdRef.current = null;
if (sonuc.ok) {
olay("liste_olusturuldu", {
tur: profil.tur,
sira_kovasi: siraKovasi(profil.sira),
});
setDurum({ ad: "geciliyor" });
// Next'in kendi gezinmesi: URL (?uret=1 düşer) ve sayfa ağacı birlikte
// güncellenir. Burada ikinci bir yönlendirici aksiyonu (refresh) YOK —
// neden: dosya başındaki not. Gezinme başlatılamazsa tam yükleme.
try {
router.replace("/listem");
} catch {
window.location.replace("/listem");
}
return;
}
if (sonuc.code === "KREDI" || sonuc.code === "PAKET") {
olay("kredi_bitti");
setDurum({ ad: "kredi-yetersiz" });
} else if (sonuc.code === "AUTH") {
router.push(`/giris?callback=${encodeURIComponent("/listem?uret=1")}`);
} else {
olay("liste_uretim_hata", { kod: sonuc.code });
setDurum({ ad: "hata", mesaj: sonuc.error });
} }
} }
// Emniyet supabı: liste kuruldu ama yönlendirici ekranı listeye çevirmediyse
// (bileşen hâlâ ekranda) tam sayfa yüklemeyle geç. replace: geri tuşu
// ?uret=1'e dönüp üretimi yeniden başlatmasın. Ayrı olay adı OlayAdi
// birliğini (lib/analitik.ts) genişletmeyi gerektirdiğinden supap,
// liste_uretim_hata'nın `kod: "GECIS_ZORLANDI"` değeriyle ayırt edilir.
useEffect(() => {
if (durum.ad !== "geciliyor") return;
const zamanlayici = setTimeout(() => {
olay("liste_uretim_hata", { kod: "GECIS_ZORLANDI" });
window.location.replace("/listem");
}, GECIS_SINIRI_MS);
return () => {
clearTimeout(zamanlayici);
// "geciliyor" son durumdur; bu temizlik yalnızca bileşen ekrandan
// kalkarken, yani gezinme işlendikten sonra koşar — kuyruk boşken
// gönderilen refresh güvenle çalışır ve layout'taki kredi pill'ini
// (gezinme layout verisini yenilemez) günceller.
router.refresh();
};
}, [durum.ad, router]);
// İstek çok uzarsa kullanıcıyı bilgilendir. İstek iptal edilmez: cevap
// sonradan gelirse yukarıdaki akış durumu zaten ezer.
useEffect(() => {
if (durum.ad !== "uretiliyor") return;
const zamanlayici = setTimeout(() => {
setDurum((d) => (d.ad === "uretiliyor" ? { ad: "uzun-surdu" } : d));
}, UZUN_SURE_MS);
return () => clearTimeout(zamanlayici);
}, [durum.ad]);
useEffect(() => { useEffect(() => {
if (basladi.current) return; if (basladi.current) return;
basladi.current = true; basladi.current = true;
@@ -170,10 +233,10 @@ export function ListeUretici({
); );
} }
return <UretimGovdesi />; return <UretimGovdesi uzunSurdu={durum.ad === "uzun-surdu"} />;
} }
function UretimGovdesi() { function UretimGovdesi({ uzunSurdu }: { uzunSurdu: boolean }) {
const mesaj = useUretimMesaji(); const mesaj = useUretimMesaji();
return ( return (
@@ -182,9 +245,32 @@ function UretimGovdesi() {
<h1 className="font-heading text-xl font-bold sm:text-2xl"> <h1 className="font-heading text-xl font-bold sm:text-2xl">
Listen hazırlanıyor Listen hazırlanıyor
</h1> </h1>
<p className="text-xs text-slate-400"> {uzunSurdu ? (
Bu işlem yarım dakika kadar sürebilir, sayfayı kapatma. <>
</p> <p className="max-w-md text-sm text-slate-600">
Bu sefer beklenenden uzun sürdü, listen hâlâ hazırlanıyor. Bu
sayfada bekleyebilir ya da hazır olup olmadığına bakabilirsin;
bakmak kredi harcamaz.
</p>
{/* ?uret=1'siz /listem üretimi asla başlatmaz: rapor hazırsa
liste, değilse boş durum gelir — kredi ikinci kez yanmaz.
Tam yükleme: yönlendirici kuyruğu bekleyen action'ın arkasında
tıkalı olduğundan router.refresh burada işe yaramaz. replace:
geri tuşu ?uret=1'e dönüp üretimi yeniden başlatmasın. */}
<Button
variant="outline"
onClick={() => window.location.replace("/listem")}
className="mt-2 cursor-pointer"
>
Listemi kontrol et
<ArrowRight className="size-4" aria-hidden />
</Button>
</>
) : (
<p className="text-xs text-slate-400">
Bu işlem yarım dakika kadar sürebilir, sayfayı kapatma.
</p>
)}
</header> </header>
{/* Nihai sayfanın (listem-govde) grid'iyle aynı düzen: liste solda, {/* Nihai sayfanın (listem-govde) grid'iyle aynı düzen: liste solda,