From 1c49289386f0141844d028fc7cd579f6496816e7 Mon Sep 17 00:00:00 2001 From: denshooter Date: Fri, 6 Mar 2026 14:57:36 +0100 Subject: [PATCH] perf: remove TipTap/ProseMirror from client bundle, lazy-load below-fold sections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TipTap (ProseMirror) was causing: - chunks 1007 (85 KiB) and 3207 (58 KiB) in the initial bundle - Array.prototype.at/flat/flatMap, Object.fromEntries/hasOwn polyfills (ProseMirror bundles core-js for these — the 12 KiB legacy JS flag) - 2+ seconds of main thread blocking on mobile Fix: move HTML conversion to the server (API route) and pass the resulting HTML string to the client, eliminating the need to import richTextToSafeHtml (and transitively TipTap) in any client component. Changes: - app/api/content/page/route.ts: call richTextToSafeHtml server-side, add html: string to response alongside existing content - app/components/RichTextClient.tsx: accept html string, remove all TipTap imports — TipTap/ProseMirror now has zero client bundle cost - app/components/About.tsx, Contact.tsx: use cmsHtml from API - app/legal-notice/page.tsx, privacy-policy/page.tsx: same - app/components/ClientWrappers.tsx: change static imports of About, Projects, Contact, Footer to next/dynamic so their JS is in separate lazy-loaded chunks, not in the initial bundle Co-Authored-By: Claude Sonnet 4.6 --- app/api/content/page/route.ts | 4 ++++ app/components/About.tsx | 9 ++++----- app/components/ClientWrappers.tsx | 12 ++++++++---- app/components/Contact.tsx | 15 +++++++-------- app/components/RichTextClient.tsx | 13 +++++-------- app/legal-notice/page.tsx | 11 +++++------ app/privacy-policy/page.tsx | 11 +++++------ 7 files changed, 38 insertions(+), 37 deletions(-) diff --git a/app/api/content/page/route.ts b/app/api/content/page/route.ts index 4e89980..4bdab1c 100644 --- a/app/api/content/page/route.ts +++ b/app/api/content/page/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getContentByKey } from "@/lib/content"; import { getContentPage } from "@/lib/directus"; +import { richTextToSafeHtml } from "@/lib/richtext"; const CACHE_TTL = 300; // 5 minutes @@ -17,6 +18,8 @@ export async function GET(request: NextRequest) { // 1) Try Directus first const directusPage = await getContentPage(key, locale); if (directusPage) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const html = directusPage.content ? richTextToSafeHtml(directusPage.content as any) : ""; return NextResponse.json( { content: { @@ -24,6 +27,7 @@ export async function GET(request: NextRequest) { slug: directusPage.slug, locale: directusPage.locale || locale, content: directusPage.content, + html, }, source: "directus", }, diff --git a/app/components/About.tsx b/app/components/About.tsx index 520d059..a6a523f 100644 --- a/app/components/About.tsx +++ b/app/components/About.tsx @@ -3,7 +3,6 @@ import { useState, useEffect } from "react"; import { Globe, Server, Wrench, Shield, Gamepad2, Code, Activity, Lightbulb, BookOpen, MessageSquare, ArrowRight, Tv, Plane, Camera, Stars, Music, Terminal, Cpu } from "lucide-react"; import { useLocale, useTranslations } from "next-intl"; -import type { JSONContent } from "@tiptap/react"; import dynamic from "next/dynamic"; const RichTextClient = dynamic(() => import("./RichTextClient"), { ssr: false }); import CurrentlyReading from "./CurrentlyReading"; @@ -23,7 +22,7 @@ const iconMap: Record = { const About = () => { const locale = useLocale(); const t = useTranslations("home.about"); - const [cmsDoc, setCmsDoc] = useState(null); + const [cmsHtml, setCmsHtml] = useState(null); const [techStack, setTechStack] = useState([]); const [hobbies, setHobbies] = useState([]); const [snippets, setSnippets] = useState([]); @@ -44,7 +43,7 @@ const About = () => { ]); const cmsData = await cmsRes.json(); - if (cmsData?.content?.content) setCmsDoc(cmsData.content.content as JSONContent); + if (cmsData?.content?.html) setCmsHtml(cmsData.content.html as string); const techData = await techRes.json(); if (techData?.techStack) setTechStack(techData.techStack); @@ -93,8 +92,8 @@ const About = () => { - ) : cmsDoc ? ( - + ) : cmsHtml ? ( + ) : (

{t("p1")} {t("p2")}

)} diff --git a/app/components/ClientWrappers.tsx b/app/components/ClientWrappers.tsx index 5b943eb..70d6b6e 100644 --- a/app/components/ClientWrappers.tsx +++ b/app/components/ClientWrappers.tsx @@ -6,10 +6,14 @@ */ import { NextIntlClientProvider } from 'next-intl'; -import About from './About'; -import Projects from './Projects'; -import Contact from './Contact'; -import Footer from './Footer'; +import dynamic from 'next/dynamic'; + +// Lazy-load below-fold components so their JS doesn't block initial paint / LCP. +// SSR stays on (default) so content is in the initial HTML for SEO. +const About = dynamic(() => import('./About')); +const Projects = dynamic(() => import('./Projects')); +const Contact = dynamic(() => import('./Contact')); +const Footer = dynamic(() => import('./Footer')); import type { AboutTranslations, ProjectsTranslations, diff --git a/app/components/Contact.tsx b/app/components/Contact.tsx index 35202b0..0fed48b 100644 --- a/app/components/Contact.tsx +++ b/app/components/Contact.tsx @@ -5,7 +5,6 @@ import { motion } from "framer-motion"; import { Mail, MapPin, Send, Github, Linkedin } from "lucide-react"; import { useToast } from "@/components/Toast"; import { useLocale, useTranslations } from "next-intl"; -import type { JSONContent } from "@tiptap/react"; import dynamic from "next/dynamic"; const RichTextClient = dynamic(() => import("./RichTextClient"), { ssr: false }); @@ -15,7 +14,7 @@ const Contact = () => { const t = useTranslations("home.contact"); const tForm = useTranslations("home.contact.form"); const tInfo = useTranslations("home.contact.info"); - const [cmsDoc, setCmsDoc] = useState(null); + const [cmsHtml, setCmsHtml] = useState(null); useEffect(() => { (async () => { @@ -25,14 +24,14 @@ const Contact = () => { ); const data = await res.json(); // Only use CMS content if it exists for the active locale. - if (data?.content?.content && data?.content?.locale === locale) { - setCmsDoc(data.content.content as JSONContent); + if (data?.content?.html && data?.content?.locale === locale) { + setCmsHtml(data.content.html as string); } else { - setCmsDoc(null); + setCmsHtml(null); } } catch { // ignore; fallback to static - setCmsDoc(null); + setCmsHtml(null); } })(); }, [locale]); @@ -169,8 +168,8 @@ const Contact = () => {

{t("title")}.

- {cmsDoc ? ( - + {cmsHtml ? ( + ) : (

{t("subtitle")} diff --git a/app/components/RichTextClient.tsx b/app/components/RichTextClient.tsx index 1813c51..e9abf7d 100644 --- a/app/components/RichTextClient.tsx +++ b/app/components/RichTextClient.tsx @@ -1,22 +1,19 @@ "use client"; -import React, { useMemo } from "react"; -import type { JSONContent } from "@tiptap/react"; -import { richTextToSafeHtml } from "@/lib/richtext"; +import React from "react"; +// Accepts pre-sanitized HTML string (converted server-side via richTextToSafeHtml). +// This keeps TipTap/ProseMirror out of the client bundle entirely. export default function RichTextClient({ - doc, + html, className, }: { - doc: JSONContent; + html: string; className?: string; }) { - const html = useMemo(() => richTextToSafeHtml(doc), [doc]); - return (

); diff --git a/app/legal-notice/page.tsx b/app/legal-notice/page.tsx index 9278a9f..31e55b0 100644 --- a/app/legal-notice/page.tsx +++ b/app/legal-notice/page.tsx @@ -8,13 +8,12 @@ import Footer from "../components/Footer"; import Link from "next/link"; import { useLocale, useTranslations } from "next-intl"; import { useEffect, useState } from "react"; -import type { JSONContent } from "@tiptap/react"; import RichTextClient from "../components/RichTextClient"; export default function LegalNotice() { const locale = useLocale(); const t = useTranslations("common"); - const [cmsDoc, setCmsDoc] = useState(null); + const [cmsHtml, setCmsHtml] = useState(null); useEffect(() => { (async () => { @@ -23,8 +22,8 @@ export default function LegalNotice() { `/api/content/page?key=${encodeURIComponent("legal-notice")}&locale=${encodeURIComponent(locale)}`, ); const data = await res.json(); - if (data?.content?.content && data?.content?.locale === locale) { - setCmsDoc(data.content.content as JSONContent); + if (data?.content?.html && data?.content?.locale === locale) { + setCmsHtml(data.content.html as string); } } catch {} })(); @@ -64,9 +63,9 @@ export default function LegalNotice() { transition={{ delay: 0.1 }} className="lg:col-span-8 bg-white dark:bg-stone-900 rounded-[3rem] p-10 md:p-16 border border-stone-200/60 dark:border-stone-800/60 shadow-sm" > - {cmsDoc ? ( + {cmsHtml ? (
- +
) : (
diff --git a/app/privacy-policy/page.tsx b/app/privacy-policy/page.tsx index 8579224..e0648ab 100644 --- a/app/privacy-policy/page.tsx +++ b/app/privacy-policy/page.tsx @@ -8,13 +8,12 @@ import Footer from "../components/Footer"; import Link from "next/link"; import { useLocale, useTranslations } from "next-intl"; import { useEffect, useState } from "react"; -import type { JSONContent } from "@tiptap/react"; import RichTextClient from "../components/RichTextClient"; export default function PrivacyPolicy() { const locale = useLocale(); const t = useTranslations("common"); - const [cmsDoc, setCmsDoc] = useState(null); + const [cmsHtml, setCmsHtml] = useState(null); useEffect(() => { (async () => { @@ -23,8 +22,8 @@ export default function PrivacyPolicy() { `/api/content/page?key=${encodeURIComponent("privacy-policy")}&locale=${encodeURIComponent(locale)}`, ); const data = await res.json(); - if (data?.content?.content && data?.content?.locale === locale) { - setCmsDoc(data.content.content as JSONContent); + if (data?.content?.html && data?.content?.locale === locale) { + setCmsHtml(data.content.html as string); } } catch {} })(); @@ -64,9 +63,9 @@ export default function PrivacyPolicy() { transition={{ delay: 0.1 }} className="lg:col-span-8 bg-white dark:bg-stone-900 rounded-[3rem] p-10 md:p-16 border border-stone-200/60 dark:border-stone-800/60 shadow-sm" > - {cmsDoc ? ( + {cmsHtml ? (
- +
) : (