perf: TercihHaritasi dinamik + lazy sarmalayıcılara kapı/preload (Faz 3)

- tercih-haritasi-lazy: turkey-map-react il geometrisi (~197 KB) dynamic
  ssr:false + viewBox oranlı iskelet (CLS yok); /sonuc 1286->1099 KB,
  /listem 1503->1315 KB eager JS
- manuel-harita: liste boşken harita hiç render edilmez (boş haritaya
  197 KB iniyordu) — aynı oranlı placeholder alanı korur
- program tablosu section'ına hover/focus preload (niyet anında ısıtma)
- liste-cekmecesi/kaydet-banner/tercih-profili-kapisi lazy sarmalayıcıları
  kapı kazandı: koşul sağlanana dek chunk inmez; açan butonlara
  (listem-butonu, + butonu, navbar sıralama butonları) hover/focus preload
- liste-cekmecesi: kullanılmayan DrawerDescription importu temizlendi

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
bilalgursen
2026-08-08 13:57:48 +03:00
parent 026debdc06
commit b7e10efd1d
11 changed files with 125 additions and 17 deletions

View File

@@ -20,6 +20,7 @@ import {
sonucaGitIste, sonucaGitIste,
useTercihProfili, useTercihProfili,
} from "@/features/liste/hooks/use-tercih-profili"; } from "@/features/liste/hooks/use-tercih-profili";
import { preloadTercihProfiliKapisi } from "@/features/liste/components/tercih-profili-kapisi-lazy";
import { PUAN_TURU_ETIKET } from "@/features/sihirbaz/sihirbaz-sabitler"; import { PUAN_TURU_ETIKET } from "@/features/sihirbaz/sihirbaz-sabitler";
import { olay } from "@/lib/analitik"; import { olay } from "@/lib/analitik";
import { import {
@@ -256,6 +257,8 @@ export function KatalogArama({ rehberler }: { rehberler: RehberAramaKaydi[] }) {
<button <button
type="button" type="button"
onClick={profilDuzenlemeyiAc} onClick={profilDuzenlemeyiAc}
onPointerEnter={preloadTercihProfiliKapisi}
onFocus={preloadTercihProfiliKapisi}
aria-label={`Sıralamanı ve tercihlerini değiştir (${profil.sira.toLocaleString("tr-TR")} · ${PUAN_TURU_ETIKET[profil.tur] ?? profil.tur})`} aria-label={`Sıralamanı ve tercihlerini değiştir (${profil.sira.toLocaleString("tr-TR")} · ${PUAN_TURU_ETIKET[profil.tur] ?? profil.tur})`}
className="group/profil flex cursor-pointer items-center gap-2 rounded-full py-1.5 pl-4 pr-1.5 transition-colors duration-150 hover:bg-slate-100 focus-visible:bg-slate-100 focus-visible:outline-none" className="group/profil flex cursor-pointer items-center gap-2 rounded-full py-1.5 pl-4 pr-1.5 transition-colors duration-150 hover:bg-slate-100 focus-visible:bg-slate-100 focus-visible:outline-none"
> >
@@ -282,6 +285,8 @@ export function KatalogArama({ rehberler }: { rehberler: RehberAramaKaydi[] }) {
<button <button
type="button" type="button"
onClick={siralamaGirVeyaSonucaGit} onClick={siralamaGirVeyaSonucaGit}
onPointerEnter={preloadTercihProfiliKapisi}
onFocus={preloadTercihProfiliKapisi}
className="flex cursor-pointer items-center gap-2 rounded-full px-4 py-1.5 text-slate-700 transition-colors duration-200 hover:bg-slate-100 hover:text-slate-900" className="flex cursor-pointer items-center gap-2 rounded-full px-4 py-1.5 text-slate-700 transition-colors duration-200 hover:bg-slate-100 hover:text-slate-900"
> >
<Trophy className="size-4 text-orange-500" aria-hidden /> <Trophy className="size-4 text-orange-500" aria-hidden />
@@ -317,6 +322,8 @@ export function KatalogArama({ rehberler }: { rehberler: RehberAramaKaydi[] }) {
<button <button
type="button" type="button"
onClick={profilDuzenlemeyiAc} onClick={profilDuzenlemeyiAc}
onPointerEnter={preloadTercihProfiliKapisi}
onFocus={preloadTercihProfiliKapisi}
aria-label={`Sıralamanı ve tercihlerini değiştir (${profil.sira.toLocaleString("tr-TR")} · ${PUAN_TURU_ETIKET[profil.tur] ?? profil.tur})`} aria-label={`Sıralamanı ve tercihlerini değiştir (${profil.sira.toLocaleString("tr-TR")} · ${PUAN_TURU_ETIKET[profil.tur] ?? profil.tur})`}
className="flex h-9 min-w-0 flex-1 cursor-pointer items-center gap-1.5 rounded-full border border-slate-200 bg-white py-1 pl-2.5 pr-1.5 text-xs font-semibold text-slate-900 transition-[background-color,transform] duration-150 hover:bg-slate-100 active:scale-[0.96] sm:h-12 sm:gap-2 sm:pl-3.5 sm:pr-2 sm:text-sm" className="flex h-9 min-w-0 flex-1 cursor-pointer items-center gap-1.5 rounded-full border border-slate-200 bg-white py-1 pl-2.5 pr-1.5 text-xs font-semibold text-slate-900 transition-[background-color,transform] duration-150 hover:bg-slate-100 active:scale-[0.96] sm:h-12 sm:gap-2 sm:pl-3.5 sm:pr-2 sm:text-sm"
> >
@@ -342,6 +349,8 @@ export function KatalogArama({ rehberler }: { rehberler: RehberAramaKaydi[] }) {
<button <button
type="button" type="button"
onClick={siralamaGirVeyaSonucaGit} onClick={siralamaGirVeyaSonucaGit}
onPointerEnter={preloadTercihProfiliKapisi}
onFocus={preloadTercihProfiliKapisi}
aria-label="Başarı sıralamanı gir" aria-label="Başarı sıralamanı gir"
className="flex h-9 min-w-0 flex-1 cursor-pointer items-center gap-1.5 rounded-full border border-slate-200 bg-white py-1 pl-2.5 pr-2.5 text-xs font-semibold text-slate-900 transition-[background-color,transform] duration-150 hover:bg-slate-100 active:scale-[0.96] sm:h-12 sm:gap-2 sm:pl-3.5 sm:pr-3 sm:text-sm" className="flex h-9 min-w-0 flex-1 cursor-pointer items-center gap-1.5 rounded-full border border-slate-200 bg-white py-1 pl-2.5 pr-2.5 text-xs font-semibold text-slate-900 transition-[background-color,transform] duration-150 hover:bg-slate-100 active:scale-[0.96] sm:h-12 sm:gap-2 sm:pl-3.5 sm:pr-3 sm:text-sm"
> >

View File

@@ -2,10 +2,22 @@
// Banner yalnızca girişsiz + 3 seçimli kullanıcıda görünür; baseline // Banner yalnızca girişsiz + 3 seçimli kullanıcıda görünür; baseline
// bundle'a girmesin diye ssr'siz dynamic import (liste-cekmecesi-lazy deseni). // bundle'a girmesin diye ssr'siz dynamic import (liste-cekmecesi-lazy deseni).
// Kapı: liste eşiğin altındayken chunk hiç inmez. Giriş/pathname koşulları
// bileşenin kendisinde kalır — burada yalnızca en ayırt edici koşul var.
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { useManuelListe } from "../liste-store";
export const KaydetBannerLazy = dynamic( // kaydet-banner.tsx'teki ESIK ile aynı değer; oradan import etmek statik
// bağımlılıkla chunk'ı geri çekerdi.
const ESIK = 3;
const KaydetBannerIc = dynamic(
() => import("./kaydet-banner").then((m) => m.KaydetBanner), () => import("./kaydet-banner").then((m) => m.KaydetBanner),
{ ssr: false }, { ssr: false },
); );
export function KaydetBannerLazy() {
const liste = useManuelListe();
return liste.length >= ESIK ? <KaydetBannerIc /> : null;
}

View File

@@ -2,11 +2,27 @@
// Çekmece kapalıyken hiçbir şey çizmez; drag/Reorder kodunun baseline // Çekmece kapalıyken hiçbir şey çizmez; drag/Reorder kodunun baseline
// bundle'a girmemesi için ssr'siz dynamic import (layout server component // bundle'a girmemesi için ssr'siz dynamic import (layout server component
// olduğundan bu sarmalayıcı gerekli). // olduğundan bu sarmalayıcı gerekli). Kapı: liste boş ve çekmece kapalıyken
// chunk hiç inmez — hydration'la birlikte koşulsuz inmesin (bundle-conditional).
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { useCekmeceAcik, useManuelListe } from "../liste-store";
export const ListeCekmecesiLazy = dynamic( const ListeCekmecesiIc = dynamic(
() => import("./liste-cekmecesi").then((m) => m.ListeCekmecesi), () => import("./liste-cekmecesi").then((m) => m.ListeCekmecesi),
{ ssr: false }, { ssr: false },
); );
/** Navbar butonu hover/focus'unda chunk'ı önden ısıt (bundle-preload). */
export function preloadListeCekmecesi() {
void import("./liste-cekmecesi");
}
export function ListeCekmecesiLazy() {
const acik = useCekmeceAcik();
const liste = useManuelListe();
// "+" uçuş animasyon katmanı da çekmece modülünde yaşadığı için liste
// doluysa (çekmece kapalı olsa da) mount kalır; yalnızca boş+kapalı durumda
// atlanır — ilk ziyaretin en yaygın hâli.
return acik || liste.length > 0 ? <ListeCekmecesiIc /> : null;
}

View File

@@ -21,7 +21,6 @@ import {
Drawer, Drawer,
DrawerClose, DrawerClose,
DrawerContent, DrawerContent,
DrawerDescription,
DrawerFooter, DrawerFooter,
DrawerHeader, DrawerHeader,
DrawerTitle, DrawerTitle,

View File

@@ -12,6 +12,7 @@ import {
useCekmeceAcik, useCekmeceAcik,
useManuelListe, useManuelListe,
} from "../liste-store"; } from "../liste-store";
import { preloadListeCekmecesi } from "./liste-cekmecesi-lazy";
export function ListemButonu() { export function ListemButonu() {
const liste = useManuelListe(); const liste = useManuelListe();
@@ -26,6 +27,8 @@ export function ListemButonu() {
<button <button
type="button" type="button"
onClick={cekmeceDegistir} onClick={cekmeceDegistir}
onPointerEnter={preloadListeCekmecesi}
onFocus={preloadListeCekmecesi}
aria-expanded={acik} aria-expanded={acik}
aria-label={`Kendi listem — ${liste.length}/${MANUEL_LISTE_MAX} program`} aria-label={`Kendi listem — ${liste.length}/${MANUEL_LISTE_MAX} program`}
title="Tablodan seçtiğin programlar" title="Tablodan seçtiğin programlar"

View File

@@ -5,7 +5,8 @@
// abonedir; ekleme/çıkarma/sürükleme anında haritaya yansır. // abonedir; ekleme/çıkarma/sürükleme anında haritaya yansır.
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { TercihHaritasi, type HaritaPin } from "@/features/liste/components/tercih-haritasi"; import { TercihHaritasiLazy } from "@/features/liste/components/tercih-haritasi-lazy";
import type { HaritaPin } from "@/features/liste/components/tercih-haritasi";
import { ilKonum, uniAdiNormalize, uniKonum } from "@/lib/harita"; import { ilKonum, uniAdiNormalize, uniKonum } from "@/lib/harita";
import type { RiskSeviyesi } from "@/lib/risk"; import type { RiskSeviyesi } from "@/lib/risk";
import { useManuelListe } from "../liste-store"; import { useManuelListe } from "../liste-store";
@@ -53,9 +54,24 @@ export function ManuelHarita() {
return { pinler: [...gruplar.values()], haritaDisi }; return { pinler: [...gruplar.values()], haritaDisi };
}, [liste]); }, [liste]);
// Liste boşken 197 KB'lık il geometrisi hiç inmesin: aynı oranlı
// placeholder alanı rezerve eder, ilk eklemede lazy harita yerine oturur.
if (liste.length === 0) {
return (
<div>
<div className="flex aspect-[1050/447] w-full items-center justify-center rounded-xl bg-slate-100/60">
<p className="max-w-xs px-4 text-center text-xs text-slate-500">
Tablodan <span aria-hidden></span> ile ekledikçe üniversiteler
haritada belirir.
</p>
</div>
</div>
);
}
return ( return (
<div> <div>
<TercihHaritasi <TercihHaritasiLazy
pinler={pinler} pinler={pinler}
seciliIl={seciliIl} seciliIl={seciliIl}
onSeciliIlDegisti={setSeciliIl} onSeciliIlDegisti={setSeciliIl}
@@ -65,12 +81,6 @@ export function ManuelHarita() {
{haritaDisi} tercih harita dışında (KKTC vb.) {haritaDisi} tercih harita dışında (KKTC vb.)
</p> </p>
) : null} ) : null}
{liste.length === 0 ? (
<p className="mt-2 text-center text-xs text-slate-500">
Tablodan <span aria-hidden></span> ile ekledikçe üniversiteler
haritada belirir.
</p>
) : null}
</div> </div>
); );
} }

View File

@@ -11,6 +11,7 @@ import {
type ManuelKaynak, type ManuelKaynak,
} from "../liste-store"; } from "../liste-store";
import { programEkleIste } from "../hooks/use-tercih-profili"; import { programEkleIste } from "../hooks/use-tercih-profili";
import { preloadTercihProfiliKapisi } from "./tercih-profili-kapisi-lazy";
type ProgramOzet = Pick< type ProgramOzet = Pick<
Program, Program,
@@ -59,6 +60,9 @@ export function ProgramEkleButonu({
type="button" type="button"
disabled={listede || doldu} disabled={listede || doldu}
onClick={handleClick} onClick={handleClick}
// Profilsiz kullanıcıda kapıyı açar — chunk'ı niyet anında ısıt
onPointerEnter={preloadTercihProfiliKapisi}
onFocus={preloadTercihProfiliKapisi}
title={ title={
listede listede
? "Zaten seçtiklerinde" ? "Zaten seçtiklerinde"

View File

@@ -36,6 +36,7 @@ import {
} from "@/components/ui/table"; } from "@/components/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ProgramEkleButonu } from "@/features/liste/components/program-ekle-butonu"; import { ProgramEkleButonu } from "@/features/liste/components/program-ekle-butonu";
import { preloadTercihHaritasi } from "@/features/liste/components/tercih-haritasi-lazy";
import { ProgramNetleriButonu } from "@/features/katalog/components/program-netleri"; import { ProgramNetleriButonu } from "@/features/katalog/components/program-netleri";
import { UniLogo } from "@/components/uni-logo"; import { UniLogo } from "@/components/uni-logo";
@@ -146,7 +147,14 @@ export function ProgramTablosu({
} }
return ( return (
<section className="mt-16" aria-label="Sıralamanla açılan programlar"> <section
className="mt-16"
aria-label="Sıralamanla açılan programlar"
// Tabloya niyet gösteren kullanıcı büyük ihtimalle ile harita
// çizdirecek — chunk'ı görünmeden ısıt (bundle-preload)
onPointerEnter={preloadTercihHaritasi}
onFocusCapture={preloadTercihHaritasi}
>
<Tabs <Tabs
value={aktifDilim} value={aktifDilim}
onValueChange={(v) => setAktifDilim(v as DilimKey)} onValueChange={(v) => setAktifDilim(v as DilimKey)}

View File

@@ -0,0 +1,27 @@
"use client";
// turkey-map-react'ın il geometrisi (~197 KB) yalnızca harita gerçekten
// çizileceği zaman insin: dynamic + ssr'siz. İskelet, haritanın gerçek
// oranını (viewBox 1050 × 447, bkz. tercih-haritasi TAM_GORUNUM) rezerve
// eder — chunk gelince yerleşim zıplamaz.
import dynamic from "next/dynamic";
export function HaritaIskeleti() {
return (
<div
aria-hidden
className="aspect-[1050/447] w-full animate-pulse rounded-xl bg-slate-100"
/>
);
}
export const TercihHaritasiLazy = dynamic(
() => import("./tercih-haritasi").then((m) => m.TercihHaritasi),
{ ssr: false, loading: () => <HaritaIskeleti /> },
);
/** Hover/focus gibi niyet sinyallerinde chunk'ı önden ısıt. */
export function preloadTercihHaritasi() {
void import("./tercih-haritasi");
}

View File

@@ -1,8 +1,28 @@
"use client"; "use client";
import dynamic from "next/dynamic"; // Sıra+sihirbaz kapısı ilk açılış isteğine kadar hiç inmez (bundle-conditional).
// Bir kez açıldıktan sonra mount kalır ki Dialog'un kapanış animasyonu ve
// oturum içi state'i bozulmasın.
export const TercihProfiliKapisiLazy = dynamic( import { useState } from "react";
import dynamic from "next/dynamic";
import { useProfilKapisi } from "../hooks/use-tercih-profili";
const TercihProfiliKapisiIc = dynamic(
() => import("./tercih-profili-kapisi").then((m) => m.TercihProfiliKapisi), () => import("./tercih-profili-kapisi").then((m) => m.TercihProfiliKapisi),
{ ssr: false }, { ssr: false },
); );
/** Kapıyı açan butonların hover/focus'unda chunk'ı önden ısıt. */
export function preloadTercihProfiliKapisi() {
void import("./tercih-profili-kapisi");
}
export function TercihProfiliKapisiLazy() {
const { acik } = useProfilKapisi();
const [gerekli, setGerekli] = useState(false);
// Render sırasında koşullu setState: ilk açılışta mandallanır (React'in
// "adjust state during render" deseni — effect'e gerek yok).
if (acik && !gerekli) setGerekli(true);
return gerekli ? <TercihProfiliKapisiIc /> : null;
}

View File

@@ -18,7 +18,7 @@ import {
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { RaporListesi } from "./rapor-listesi"; import { RaporListesi } from "./rapor-listesi";
import { useGorunumOlayi } from "@/components/use-gorunum-olayi"; import { useGorunumOlayi } from "@/components/use-gorunum-olayi";
import { TercihHaritasi } from "@/features/liste/components/tercih-haritasi"; import { TercihHaritasiLazy } from "@/features/liste/components/tercih-haritasi-lazy";
import { pinleriTuret } from "@/lib/harita-pinler"; import { pinleriTuret } from "@/lib/harita-pinler";
import type { RaporSonuc } from "@/features/rapor/types/rapor"; import type { RaporSonuc } from "@/features/rapor/types/rapor";
import { SohbetClient, type DanismanOzeti, type Mesaj } from "./sohbet-client"; import { SohbetClient, type DanismanOzeti, type Mesaj } from "./sohbet-client";
@@ -92,7 +92,7 @@ export function ListemGovde({
className="scroll-mt-24" className="scroll-mt-24"
> >
<div className="mx-auto w-full max-w-xl"> <div className="mx-auto w-full max-w-xl">
<TercihHaritasi <TercihHaritasiLazy
pinler={pinVerisi.pinler} pinler={pinVerisi.pinler}
seciliIl={seciliIl} seciliIl={seciliIl}
onSeciliIlDegisti={setSeciliIl} onSeciliIlDegisti={setSeciliIl}