perf: remove TipTap/ProseMirror from client bundle, lazy-load below-fold sections
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 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { getContentByKey } from "@/lib/content";
|
import { getContentByKey } from "@/lib/content";
|
||||||
import { getContentPage } from "@/lib/directus";
|
import { getContentPage } from "@/lib/directus";
|
||||||
|
import { richTextToSafeHtml } from "@/lib/richtext";
|
||||||
|
|
||||||
const CACHE_TTL = 300; // 5 minutes
|
const CACHE_TTL = 300; // 5 minutes
|
||||||
|
|
||||||
@@ -17,6 +18,8 @@ export async function GET(request: NextRequest) {
|
|||||||
// 1) Try Directus first
|
// 1) Try Directus first
|
||||||
const directusPage = await getContentPage(key, locale);
|
const directusPage = await getContentPage(key, locale);
|
||||||
if (directusPage) {
|
if (directusPage) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const html = directusPage.content ? richTextToSafeHtml(directusPage.content as any) : "";
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
content: {
|
content: {
|
||||||
@@ -24,6 +27,7 @@ export async function GET(request: NextRequest) {
|
|||||||
slug: directusPage.slug,
|
slug: directusPage.slug,
|
||||||
locale: directusPage.locale || locale,
|
locale: directusPage.locale || locale,
|
||||||
content: directusPage.content,
|
content: directusPage.content,
|
||||||
|
html,
|
||||||
},
|
},
|
||||||
source: "directus",
|
source: "directus",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
import { useState, useEffect } from "react";
|
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 { 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 { useLocale, useTranslations } from "next-intl";
|
||||||
import type { JSONContent } from "@tiptap/react";
|
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
const RichTextClient = dynamic(() => import("./RichTextClient"), { ssr: false });
|
const RichTextClient = dynamic(() => import("./RichTextClient"), { ssr: false });
|
||||||
import CurrentlyReading from "./CurrentlyReading";
|
import CurrentlyReading from "./CurrentlyReading";
|
||||||
@@ -23,7 +22,7 @@ const iconMap: Record<string, LucideIcon> = {
|
|||||||
const About = () => {
|
const About = () => {
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
const t = useTranslations("home.about");
|
const t = useTranslations("home.about");
|
||||||
const [cmsDoc, setCmsDoc] = useState<JSONContent | null>(null);
|
const [cmsHtml, setCmsHtml] = useState<string | null>(null);
|
||||||
const [techStack, setTechStack] = useState<TechStackCategory[]>([]);
|
const [techStack, setTechStack] = useState<TechStackCategory[]>([]);
|
||||||
const [hobbies, setHobbies] = useState<Hobby[]>([]);
|
const [hobbies, setHobbies] = useState<Hobby[]>([]);
|
||||||
const [snippets, setSnippets] = useState<Snippet[]>([]);
|
const [snippets, setSnippets] = useState<Snippet[]>([]);
|
||||||
@@ -44,7 +43,7 @@ const About = () => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const cmsData = await cmsRes.json();
|
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();
|
const techData = await techRes.json();
|
||||||
if (techData?.techStack) setTechStack(techData.techStack);
|
if (techData?.techStack) setTechStack(techData.techStack);
|
||||||
@@ -93,8 +92,8 @@ const About = () => {
|
|||||||
<Skeleton className="h-6 w-[95%]" />
|
<Skeleton className="h-6 w-[95%]" />
|
||||||
<Skeleton className="h-6 w-[90%]" />
|
<Skeleton className="h-6 w-[90%]" />
|
||||||
</div>
|
</div>
|
||||||
) : cmsDoc ? (
|
) : cmsHtml ? (
|
||||||
<RichTextClient doc={cmsDoc} />
|
<RichTextClient html={cmsHtml} />
|
||||||
) : (
|
) : (
|
||||||
<p>{t("p1")} {t("p2")}</p>
|
<p>{t("p1")} {t("p2")}</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -6,10 +6,14 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { NextIntlClientProvider } from 'next-intl';
|
import { NextIntlClientProvider } from 'next-intl';
|
||||||
import About from './About';
|
import dynamic from 'next/dynamic';
|
||||||
import Projects from './Projects';
|
|
||||||
import Contact from './Contact';
|
// Lazy-load below-fold components so their JS doesn't block initial paint / LCP.
|
||||||
import Footer from './Footer';
|
// 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 {
|
import type {
|
||||||
AboutTranslations,
|
AboutTranslations,
|
||||||
ProjectsTranslations,
|
ProjectsTranslations,
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { motion } from "framer-motion";
|
|||||||
import { Mail, MapPin, Send, Github, Linkedin } from "lucide-react";
|
import { Mail, MapPin, Send, Github, Linkedin } from "lucide-react";
|
||||||
import { useToast } from "@/components/Toast";
|
import { useToast } from "@/components/Toast";
|
||||||
import { useLocale, useTranslations } from "next-intl";
|
import { useLocale, useTranslations } from "next-intl";
|
||||||
import type { JSONContent } from "@tiptap/react";
|
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
const RichTextClient = dynamic(() => import("./RichTextClient"), { ssr: false });
|
const RichTextClient = dynamic(() => import("./RichTextClient"), { ssr: false });
|
||||||
|
|
||||||
@@ -15,7 +14,7 @@ const Contact = () => {
|
|||||||
const t = useTranslations("home.contact");
|
const t = useTranslations("home.contact");
|
||||||
const tForm = useTranslations("home.contact.form");
|
const tForm = useTranslations("home.contact.form");
|
||||||
const tInfo = useTranslations("home.contact.info");
|
const tInfo = useTranslations("home.contact.info");
|
||||||
const [cmsDoc, setCmsDoc] = useState<JSONContent | null>(null);
|
const [cmsHtml, setCmsHtml] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
@@ -25,14 +24,14 @@ const Contact = () => {
|
|||||||
);
|
);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
// Only use CMS content if it exists for the active locale.
|
// Only use CMS content if it exists for the active locale.
|
||||||
if (data?.content?.content && data?.content?.locale === locale) {
|
if (data?.content?.html && data?.content?.locale === locale) {
|
||||||
setCmsDoc(data.content.content as JSONContent);
|
setCmsHtml(data.content.html as string);
|
||||||
} else {
|
} else {
|
||||||
setCmsDoc(null);
|
setCmsHtml(null);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore; fallback to static
|
// ignore; fallback to static
|
||||||
setCmsDoc(null);
|
setCmsHtml(null);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}, [locale]);
|
}, [locale]);
|
||||||
@@ -169,8 +168,8 @@ const Contact = () => {
|
|||||||
<h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-7xl font-black text-stone-900 dark:text-stone-50 tracking-tighter uppercase mb-4 sm:mb-6 md:mb-8">
|
<h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-7xl font-black text-stone-900 dark:text-stone-50 tracking-tighter uppercase mb-4 sm:mb-6 md:mb-8">
|
||||||
{t("title")}<span className="text-emerald-600 dark:text-emerald-400">.</span>
|
{t("title")}<span className="text-emerald-600 dark:text-emerald-400">.</span>
|
||||||
</h2>
|
</h2>
|
||||||
{cmsDoc ? (
|
{cmsHtml ? (
|
||||||
<RichTextClient doc={cmsDoc} className="prose prose-stone dark:prose-invert max-w-none text-base sm:text-lg md:text-xl lg:text-2xl font-light leading-relaxed text-stone-600 dark:text-stone-400" />
|
<RichTextClient html={cmsHtml} className="prose prose-stone dark:prose-invert max-w-none text-base sm:text-lg md:text-xl lg:text-2xl font-light leading-relaxed text-stone-600 dark:text-stone-400" />
|
||||||
) : (
|
) : (
|
||||||
<p className="text-base sm:text-lg md:text-xl lg:text-2xl font-light leading-relaxed text-stone-600 dark:text-stone-400">
|
<p className="text-base sm:text-lg md:text-xl lg:text-2xl font-light leading-relaxed text-stone-600 dark:text-stone-400">
|
||||||
{t("subtitle")}
|
{t("subtitle")}
|
||||||
|
|||||||
@@ -1,22 +1,19 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useMemo } from "react";
|
import React from "react";
|
||||||
import type { JSONContent } from "@tiptap/react";
|
|
||||||
import { richTextToSafeHtml } from "@/lib/richtext";
|
|
||||||
|
|
||||||
|
// Accepts pre-sanitized HTML string (converted server-side via richTextToSafeHtml).
|
||||||
|
// This keeps TipTap/ProseMirror out of the client bundle entirely.
|
||||||
export default function RichTextClient({
|
export default function RichTextClient({
|
||||||
doc,
|
html,
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
doc: JSONContent;
|
html: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const html = useMemo(() => richTextToSafeHtml(doc), [doc]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={className}
|
className={className}
|
||||||
// HTML is sanitized in `richTextToSafeHtml`
|
|
||||||
dangerouslySetInnerHTML={{ __html: html }}
|
dangerouslySetInnerHTML={{ __html: html }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,13 +8,12 @@ import Footer from "../components/Footer";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useLocale, useTranslations } from "next-intl";
|
import { useLocale, useTranslations } from "next-intl";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import type { JSONContent } from "@tiptap/react";
|
|
||||||
import RichTextClient from "../components/RichTextClient";
|
import RichTextClient from "../components/RichTextClient";
|
||||||
|
|
||||||
export default function LegalNotice() {
|
export default function LegalNotice() {
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
const t = useTranslations("common");
|
const t = useTranslations("common");
|
||||||
const [cmsDoc, setCmsDoc] = useState<JSONContent | null>(null);
|
const [cmsHtml, setCmsHtml] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
@@ -23,8 +22,8 @@ export default function LegalNotice() {
|
|||||||
`/api/content/page?key=${encodeURIComponent("legal-notice")}&locale=${encodeURIComponent(locale)}`,
|
`/api/content/page?key=${encodeURIComponent("legal-notice")}&locale=${encodeURIComponent(locale)}`,
|
||||||
);
|
);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data?.content?.content && data?.content?.locale === locale) {
|
if (data?.content?.html && data?.content?.locale === locale) {
|
||||||
setCmsDoc(data.content.content as JSONContent);
|
setCmsHtml(data.content.html as string);
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
})();
|
})();
|
||||||
@@ -64,9 +63,9 @@ export default function LegalNotice() {
|
|||||||
transition={{ delay: 0.1 }}
|
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"
|
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 ? (
|
||||||
<div className="prose prose-stone dark:prose-invert max-w-none text-lg md:text-xl font-light leading-relaxed">
|
<div className="prose prose-stone dark:prose-invert max-w-none text-lg md:text-xl font-light leading-relaxed">
|
||||||
<RichTextClient doc={cmsDoc} />
|
<RichTextClient html={cmsHtml} />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-16">
|
<div className="space-y-16">
|
||||||
|
|||||||
@@ -8,13 +8,12 @@ import Footer from "../components/Footer";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useLocale, useTranslations } from "next-intl";
|
import { useLocale, useTranslations } from "next-intl";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import type { JSONContent } from "@tiptap/react";
|
|
||||||
import RichTextClient from "../components/RichTextClient";
|
import RichTextClient from "../components/RichTextClient";
|
||||||
|
|
||||||
export default function PrivacyPolicy() {
|
export default function PrivacyPolicy() {
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
const t = useTranslations("common");
|
const t = useTranslations("common");
|
||||||
const [cmsDoc, setCmsDoc] = useState<JSONContent | null>(null);
|
const [cmsHtml, setCmsHtml] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
@@ -23,8 +22,8 @@ export default function PrivacyPolicy() {
|
|||||||
`/api/content/page?key=${encodeURIComponent("privacy-policy")}&locale=${encodeURIComponent(locale)}`,
|
`/api/content/page?key=${encodeURIComponent("privacy-policy")}&locale=${encodeURIComponent(locale)}`,
|
||||||
);
|
);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data?.content?.content && data?.content?.locale === locale) {
|
if (data?.content?.html && data?.content?.locale === locale) {
|
||||||
setCmsDoc(data.content.content as JSONContent);
|
setCmsHtml(data.content.html as string);
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
})();
|
})();
|
||||||
@@ -64,9 +63,9 @@ export default function PrivacyPolicy() {
|
|||||||
transition={{ delay: 0.1 }}
|
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"
|
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 ? (
|
||||||
<div className="prose prose-stone dark:prose-invert max-w-none text-lg md:text-xl font-light leading-relaxed">
|
<div className="prose prose-stone dark:prose-invert max-w-none text-lg md:text-xl font-light leading-relaxed">
|
||||||
<RichTextClient doc={cmsDoc} />
|
<RichTextClient html={cmsHtml} />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-16">
|
<div className="space-y-16">
|
||||||
|
|||||||
Reference in New Issue
Block a user