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:
bilalgursen
2026-08-08 14:01:48 +03:00
parent b7e10efd1d
commit 90ac590737
5 changed files with 110 additions and 27 deletions

View 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}</>;
}

View File

@@ -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 {

View File

@@ -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}

View File

@@ -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.

View File

@@ -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"