From 90ac590737666f1502e52fae163cbe97afccc345 Mon Sep 17 00:00:00 2001 From: bilalgursen Date: Sat, 8 Aug 2026 14:01:48 +0300 Subject: [PATCH] =?UTF-8?q?perf:=20sohbet=20markdown=20lazy=20+=20marked?= =?UTF-8?q?=20client'tan=20=C3=A7=C4=B1kt=C4=B1,=20mermaid=20g=C3=B6r?= =?UTF-8?q?=C3=BCn=C3=BCrl=C3=BCkte=20(Faz=204)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - markdown.tsx: blok bölme marked.lexer yerine çit-farkındalıklı boş satır bölücüsü — react-markdown'ın yanında ikinci tam parser (~460 KB paket) client'a inmiyordu artık gerekmiyor; memoized blok yapısı aynen korunur - markdown-lazy: react-markdown+remark zinciri (~215 KB) lazy; fallback aynı class'lı düz metin (stream'de flash/CLS yok), sohbet mount'unda preloadMarkdown ile chunk önden ısınır — /listem 1315->1133 KB eager JS - el-cizimi-sema: mermaid importu IntersectionObserver'a bağlandı (rootMargin 200px); şemalar hep fold altında, ~1 MB mount'ta inmiyor Co-Authored-By: Claude Fable 5 --- src/components/ui/markdown-lazy.tsx | 32 ++++++++++ src/components/ui/markdown.tsx | 23 ++++++- src/components/ui/message.tsx | 15 +++-- .../rapor/components/sohbet-client.tsx | 7 +++ .../rehber/components/el-cizimi-sema.tsx | 60 ++++++++++++------- 5 files changed, 110 insertions(+), 27 deletions(-) create mode 100644 src/components/ui/markdown-lazy.tsx diff --git a/src/components/ui/markdown-lazy.tsx b/src/components/ui/markdown-lazy.tsx new file mode 100644 index 0000000..672e96a --- /dev/null +++ b/src/components/ui/markdown-lazy.tsx @@ -0,0 +1,32 @@ +"use client"; + +// react-markdown + remark zinciri (~215 KB) yalnızca markdown gerçekten +// çizileceği zaman insin. next/dynamic yerine elle sarmalayıcı: `loading` +// children alamıyor, fallback'in AYNI class'lı düz metin olması gerekiyor ki +// stream sırasında iskelet/boşluk flash'ı olmasın (chunk gelince yerinde +// zenginleşir). + +import { useEffect, useReducer } from "react"; +import type { MarkdownProps } from "./markdown"; + +let mod: typeof import("./markdown") | null = null; +let soz: Promise | null = null; + +/** Chunk'ı önden ısıt (ör. sohbet mount olduğunda). */ +export function preloadMarkdown(): Promise { + return (soz ??= import("./markdown").then((m) => { + mod = m; + })); +} + +export function MarkdownLazy({ + fallback, + ...props +}: MarkdownProps & { fallback: React.ReactNode }) { + const [, tazele] = useReducer((c: number) => c + 1, 0); + useEffect(() => { + if (!mod) void preloadMarkdown().then(() => tazele()); + }, []); + const Markdown = mod?.Markdown; + return Markdown ? : <>{fallback}; +} diff --git a/src/components/ui/markdown.tsx b/src/components/ui/markdown.tsx index 6fda0f3..a00d424 100644 --- a/src/components/ui/markdown.tsx +++ b/src/components/ui/markdown.tsx @@ -1,5 +1,4 @@ import { cn } from "@/lib/utils" -import { marked } from "marked" import { memo, useId, useMemo } from "react" import ReactMarkdown, { Components } from "react-markdown" import remarkBreaks from "remark-breaks" @@ -13,9 +12,27 @@ export type MarkdownProps = { components?: Partial } +// Stream memoization'ı için bloklara bölme. Eskiden marked.lexer yapıyordu — +// react-markdown'ın micromark'ına ek ikinci bir tam parser'ı (~460 KB paket) +// client'a taşımak blok bölmek için gereksiz: kod çiti dışındaki boş satırlar +// zaten markdown'da blok sınırıdır. Çit içi boş satırlar korunur. function parseMarkdownIntoBlocks(markdown: string): string[] { - const tokens = marked.lexer(markdown) - return tokens.map((token) => token.raw) + const bloklar: string[] = [] + let mevcut: string[] = [] + let citte = false + for (const satir of markdown.split("\n")) { + if (/^\s*(```|~~~)/.test(satir)) citte = !citte + if (!citte && satir.trim() === "") { + if (mevcut.length) { + bloklar.push(mevcut.join("\n")) + mevcut = [] + } + } else { + mevcut.push(satir) + } + } + if (mevcut.length) bloklar.push(mevcut.join("\n")) + return bloklar.length ? bloklar : [markdown] } function extractLanguage(className?: string): string { diff --git a/src/components/ui/message.tsx b/src/components/ui/message.tsx index 3a11f55..b43cc32 100644 --- a/src/components/ui/message.tsx +++ b/src/components/ui/message.tsx @@ -6,7 +6,8 @@ import { TooltipTrigger, } from "@/components/ui/tooltip" import { cn } from "@/lib/utils" -import { Markdown } from "./markdown" +import { MarkdownLazy } from "./markdown-lazy" +import type { MarkdownProps } from "./markdown" export type MessageProps = { children: React.ReactNode @@ -48,7 +49,7 @@ export type MessageContentProps = { children: React.ReactNode markdown?: boolean className?: string -} & React.ComponentProps & +} & MarkdownProps & React.HTMLProps const MessageContent = ({ @@ -63,9 +64,15 @@ const MessageContent = ({ ) return markdown ? ( - + // Fallback aynı class'lı düz metin: chunk inene dek CLS/flash olmadan + // ham içerik görünür, sonra yerinde zenginleşir. + {children}} + {...props} + > {children as string} - + ) : (
{children} diff --git a/src/features/rapor/components/sohbet-client.tsx b/src/features/rapor/components/sohbet-client.tsx index 536c42a..9508c1a 100644 --- a/src/features/rapor/components/sohbet-client.tsx +++ b/src/features/rapor/components/sohbet-client.tsx @@ -14,6 +14,7 @@ import { ChatContainerScrollAnchor, } from "@/components/ui/chat-container"; import { Message, MessageContent } from "@/components/ui/message"; +import { preloadMarkdown } from "@/components/ui/markdown-lazy"; import { PromptInput, PromptInputAction, @@ -90,6 +91,12 @@ export function SohbetClient({ const [kredi, setKredi] = useState(baslangicKredi); const router = useRouter(); + // Markdown chunk'ını sohbet görünür olur olmaz ısıt: ilk danışman cevabı + // (veya geçmiş mesajlar) çizilirken lazy sınırında düz-metin flash'ı olmasın. + useEffect(() => { + void preloadMarkdown(); + }, []); + useEffect(() => { // Geçmiş sunucudan geldiyse (listem sayfası) ekstra tur atma; // popup gibi geç mount olan yerlerde eski davranış sürer. diff --git a/src/features/rehber/components/el-cizimi-sema.tsx b/src/features/rehber/components/el-cizimi-sema.tsx index 29c461f..ad0955f 100644 --- a/src/features/rehber/components/el-cizimi-sema.tsx +++ b/src/features/rehber/components/el-cizimi-sema.tsx @@ -6,7 +6,7 @@ // Mermaid ağır bir paket olduğu için useEffect içinde dinamik import edilir; // sayfa paketine girmez. -import { useEffect, useId, useState } from "react"; +import { useEffect, useId, useRef, useState } from "react"; import localFont from "next/font/local"; // Şemanın "el çizimi" görünümüne uyan el yazısı fontu (Sweet Cucumber @@ -30,38 +30,58 @@ export function ElCizimiSema({ figcaption?: React.ReactNode; }) { const [svg, setSvg] = useState(null); + const figureRef = useRef(null); // mermaid.render'ın istediği DOM id'si: useId'nin ":" karakterleri geçersiz const semaId = `sema-${useId().replace(/[^a-zA-Z0-9-]/g, "")}`; + // ~1 MB'lık mermaid mount'ta değil, şema görünürlüğe yaklaşınca insin — + // şemalar hep fold altında (/meraklisina sayfa sonu, rehber makale ortası). useEffect(() => { + const el = figureRef.current; + if (!el) return; let iptal = false; - import("mermaid").then(async ({ default: mermaid }) => { - mermaid.initialize({ - startOnLoad: false, - look: "handDrawn", - handDrawnSeed: 7, // her render'da aynı çizgi titremesi (deterministik) - theme: "base", - fontFamily: `${elYazisi.style.fontFamily}, cursive`, - themeVariables: { - primaryColor: "#ffffff", - primaryBorderColor: "#e2e8f0", - primaryTextColor: "#0f172a", - lineColor: "#94a3b8", - edgeLabelBackground: "#ffffff", + const io = new IntersectionObserver( + (girisler) => { + if (!girisler.some((g) => g.isIntersecting)) return; + io.disconnect(); + cizdir(); + }, + // Kullanıcı şemaya varmadan yükleme başlasın + { rootMargin: "200px 0px" }, + ); + io.observe(el); + + function cizdir() { + import("mermaid").then(async ({ default: mermaid }) => { + mermaid.initialize({ + startOnLoad: false, + look: "handDrawn", + handDrawnSeed: 7, // her render'da aynı çizgi titremesi (deterministik) + theme: "base", fontFamily: `${elYazisi.style.fontFamily}, cursive`, - fontSize: "17px", // script fontun x-yüksekliği düşük; okunurluk için büyütüldü - }, + themeVariables: { + primaryColor: "#ffffff", + primaryBorderColor: "#e2e8f0", + primaryTextColor: "#0f172a", + lineColor: "#94a3b8", + edgeLabelBackground: "#ffffff", + fontFamily: `${elYazisi.style.fontFamily}, cursive`, + fontSize: "17px", // script fontun x-yüksekliği düşük; okunurluk için büyütüldü + }, + }); + const sonuc = await mermaid.render(semaId, tanim); + if (!iptal) setSvg(sonuc.svg); }); - const sonuc = await mermaid.render(semaId, tanim); - if (!iptal) setSvg(sonuc.svg); - }); + } + return () => { iptal = true; + io.disconnect(); }; }, [semaId, tanim]); return ( -
+
{svg ? (