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:
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)}
|
||||
|
||||
27
src/features/liste/components/tercih-haritasi-lazy.tsx
Normal file
27
src/features/liste/components/tercih-haritasi-lazy.tsx
Normal 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");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user