perf: sohbet markdown lazy + marked client'tan çıktı, mermaid görünürlükte (Faz 4)
- 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 <noreply@anthropic.com>
This commit is contained in:
32
src/components/ui/markdown-lazy.tsx
Normal file
32
src/components/ui/markdown-lazy.tsx
Normal file
@@ -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<void> | null = null;
|
||||
|
||||
/** Chunk'ı önden ısıt (ör. sohbet mount olduğunda). */
|
||||
export function preloadMarkdown(): Promise<void> {
|
||||
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 ? <Markdown {...props} /> : <>{fallback}</>;
|
||||
}
|
||||
@@ -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<Components>
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
||||
@@ -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<typeof Markdown> &
|
||||
} & MarkdownProps &
|
||||
React.HTMLProps<HTMLDivElement>
|
||||
|
||||
const MessageContent = ({
|
||||
@@ -63,9 +64,15 @@ const MessageContent = ({
|
||||
)
|
||||
|
||||
return markdown ? (
|
||||
<Markdown className={classNames} {...props}>
|
||||
// Fallback aynı class'lı düz metin: chunk inene dek CLS/flash olmadan
|
||||
// ham içerik görünür, sonra yerinde zenginleşir.
|
||||
<MarkdownLazy
|
||||
className={classNames}
|
||||
fallback={<div className={classNames}>{children}</div>}
|
||||
{...props}
|
||||
>
|
||||
{children as string}
|
||||
</Markdown>
|
||||
</MarkdownLazy>
|
||||
) : (
|
||||
<div className={classNames} {...props}>
|
||||
{children}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const figureRef = useRef<HTMLElement>(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 (
|
||||
<figure className="mt-6">
|
||||
<figure ref={figureRef} className="mt-6">
|
||||
{svg ? (
|
||||
<div
|
||||
role="img"
|
||||
|
||||
Reference in New Issue
Block a user