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 { cn } from "@/lib/utils"
|
||||||
import { marked } from "marked"
|
|
||||||
import { memo, useId, useMemo } from "react"
|
import { memo, useId, useMemo } from "react"
|
||||||
import ReactMarkdown, { Components } from "react-markdown"
|
import ReactMarkdown, { Components } from "react-markdown"
|
||||||
import remarkBreaks from "remark-breaks"
|
import remarkBreaks from "remark-breaks"
|
||||||
@@ -13,9 +12,27 @@ export type MarkdownProps = {
|
|||||||
components?: Partial<Components>
|
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[] {
|
function parseMarkdownIntoBlocks(markdown: string): string[] {
|
||||||
const tokens = marked.lexer(markdown)
|
const bloklar: string[] = []
|
||||||
return tokens.map((token) => token.raw)
|
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 {
|
function extractLanguage(className?: string): string {
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import {
|
|||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip"
|
} from "@/components/ui/tooltip"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { Markdown } from "./markdown"
|
import { MarkdownLazy } from "./markdown-lazy"
|
||||||
|
import type { MarkdownProps } from "./markdown"
|
||||||
|
|
||||||
export type MessageProps = {
|
export type MessageProps = {
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
@@ -48,7 +49,7 @@ export type MessageContentProps = {
|
|||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
markdown?: boolean
|
markdown?: boolean
|
||||||
className?: string
|
className?: string
|
||||||
} & React.ComponentProps<typeof Markdown> &
|
} & MarkdownProps &
|
||||||
React.HTMLProps<HTMLDivElement>
|
React.HTMLProps<HTMLDivElement>
|
||||||
|
|
||||||
const MessageContent = ({
|
const MessageContent = ({
|
||||||
@@ -63,9 +64,15 @@ const MessageContent = ({
|
|||||||
)
|
)
|
||||||
|
|
||||||
return markdown ? (
|
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}
|
{children as string}
|
||||||
</Markdown>
|
</MarkdownLazy>
|
||||||
) : (
|
) : (
|
||||||
<div className={classNames} {...props}>
|
<div className={classNames} {...props}>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
ChatContainerScrollAnchor,
|
ChatContainerScrollAnchor,
|
||||||
} from "@/components/ui/chat-container";
|
} from "@/components/ui/chat-container";
|
||||||
import { Message, MessageContent } from "@/components/ui/message";
|
import { Message, MessageContent } from "@/components/ui/message";
|
||||||
|
import { preloadMarkdown } from "@/components/ui/markdown-lazy";
|
||||||
import {
|
import {
|
||||||
PromptInput,
|
PromptInput,
|
||||||
PromptInputAction,
|
PromptInputAction,
|
||||||
@@ -90,6 +91,12 @@ export function SohbetClient({
|
|||||||
const [kredi, setKredi] = useState(baslangicKredi);
|
const [kredi, setKredi] = useState(baslangicKredi);
|
||||||
const router = useRouter();
|
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(() => {
|
useEffect(() => {
|
||||||
// Geçmiş sunucudan geldiyse (listem sayfası) ekstra tur atma;
|
// Geçmiş sunucudan geldiyse (listem sayfası) ekstra tur atma;
|
||||||
// popup gibi geç mount olan yerlerde eski davranış sürer.
|
// 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;
|
// Mermaid ağır bir paket olduğu için useEffect içinde dinamik import edilir;
|
||||||
// sayfa paketine girmez.
|
// sayfa paketine girmez.
|
||||||
|
|
||||||
import { useEffect, useId, useState } from "react";
|
import { useEffect, useId, useRef, useState } from "react";
|
||||||
import localFont from "next/font/local";
|
import localFont from "next/font/local";
|
||||||
|
|
||||||
// Şemanın "el çizimi" görünümüne uyan el yazısı fontu (Sweet Cucumber
|
// Şemanın "el çizimi" görünümüne uyan el yazısı fontu (Sweet Cucumber
|
||||||
@@ -30,11 +30,28 @@ export function ElCizimiSema({
|
|||||||
figcaption?: React.ReactNode;
|
figcaption?: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const [svg, setSvg] = useState<string | null>(null);
|
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
|
// mermaid.render'ın istediği DOM id'si: useId'nin ":" karakterleri geçersiz
|
||||||
const semaId = `sema-${useId().replace(/[^a-zA-Z0-9-]/g, "")}`;
|
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(() => {
|
useEffect(() => {
|
||||||
|
const el = figureRef.current;
|
||||||
|
if (!el) return;
|
||||||
let iptal = false;
|
let iptal = false;
|
||||||
|
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 }) => {
|
import("mermaid").then(async ({ default: mermaid }) => {
|
||||||
mermaid.initialize({
|
mermaid.initialize({
|
||||||
startOnLoad: false,
|
startOnLoad: false,
|
||||||
@@ -55,13 +72,16 @@ export function ElCizimiSema({
|
|||||||
const sonuc = await mermaid.render(semaId, tanim);
|
const sonuc = await mermaid.render(semaId, tanim);
|
||||||
if (!iptal) setSvg(sonuc.svg);
|
if (!iptal) setSvg(sonuc.svg);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
iptal = true;
|
iptal = true;
|
||||||
|
io.disconnect();
|
||||||
};
|
};
|
||||||
}, [semaId, tanim]);
|
}, [semaId, tanim]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<figure className="mt-6">
|
<figure ref={figureRef} className="mt-6">
|
||||||
{svg ? (
|
{svg ? (
|
||||||
<div
|
<div
|
||||||
role="img"
|
role="img"
|
||||||
|
|||||||
Reference in New Issue
Block a user