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,
useTercihProfili,
} 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 { olay } from "@/lib/analitik";
import {
@@ -256,6 +257,8 @@ export function KatalogArama({ rehberler }: { rehberler: RehberAramaKaydi[] }) {
<button
type="button"
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})`}
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
type="button"
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"
>
<Trophy className="size-4 text-orange-500" aria-hidden />
@@ -317,6 +322,8 @@ export function KatalogArama({ rehberler }: { rehberler: RehberAramaKaydi[] }) {
<button
type="button"
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})`}
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
type="button"
onClick={siralamaGirVeyaSonucaGit}
onPointerEnter={preloadTercihProfiliKapisi}
onFocus={preloadTercihProfiliKapisi}
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"
>

View File

@@ -2,10 +2,22 @@
// 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).
// 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 { 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),
{ 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
// 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 { useCekmeceAcik, useManuelListe } from "../liste-store";
export const ListeCekmecesiLazy = dynamic(
const ListeCekmecesiIc = dynamic(
() => import("./liste-cekmecesi").then((m) => m.ListeCekmecesi),
{ 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,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,

View File

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

View File

@@ -5,7 +5,8 @@
// abonedir; ekleme/çıkarma/sürükleme anında haritaya yansır.
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 type { RiskSeviyesi } from "@/lib/risk";
import { useManuelListe } from "../liste-store";
@@ -53,9 +54,24 @@ export function ManuelHarita() {
return { pinler: [...gruplar.values()], haritaDisi };
}, [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>
<TercihHaritasi
<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 (
<div>
<TercihHaritasiLazy
pinler={pinler}
seciliIl={seciliIl}
onSeciliIlDegisti={setSeciliIl}
@@ -65,12 +81,6 @@ export function ManuelHarita() {
{haritaDisi} tercih harita dışında (KKTC vb.)
</p>
) : 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>
);
}

View File

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

View File

@@ -36,6 +36,7 @@ import {
} from "@/components/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
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 { UniLogo } from "@/components/uni-logo";
@@ -146,7 +147,14 @@ export function ProgramTablosu({
}
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
value={aktifDilim}
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";
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),
{ 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 { RaporListesi } from "./rapor-listesi";
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 type { RaporSonuc } from "@/features/rapor/types/rapor";
import { SohbetClient, type DanismanOzeti, type Mesaj } from "./sohbet-client";
@@ -92,7 +92,7 @@ export function ListemGovde({
className="scroll-mt-24"
>
<div className="mx-auto w-full max-w-xl">
<TercihHaritasi
<TercihHaritasiLazy
pinler={pinVerisi.pinler}
seciliIl={seciliIl}
onSeciliIlDegisti={setSeciliIl}