perf: eliminate next-themes and framer-motion from initial JS bundle
All checks were successful
CI / CD / test-build (push) Successful in 10m10s
CI / CD / deploy-dev (push) Successful in 1m46s
CI / CD / deploy-production (push) Has been skipped

- Replace next-themes (38 KiB) with a tiny custom ThemeProvider (~< 1 KiB)
  using localStorage + classList.toggle for theme management
- Add FOUC-prevention inline script in layout.tsx to apply saved theme
  before React hydrates
- Remove framer-motion from Header.tsx: nav entry now uses CSS slideDown
  keyframe, mobile menu uses CSS opacity/translate transitions
- Remove framer-motion from ThemeToggle.tsx: use Tailwind hover/active scale
- Remove framer-motion from legal-notice and privacy-policy pages
- Update useTheme import in ThemeToggle to use custom ThemeProvider
- Add slideDown keyframe to tailwind.config.ts
- Update tests to mock custom ThemeProvider instead of next-themes

Result: framer-motion moves from "First Load JS shared by all" to lazy
chunks; next-themes chunk eliminated entirely; -38 KiB from initial bundle

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 17:39:29 +01:00
parent 7f7ed39b0e
commit dacec18956
11 changed files with 94 additions and 91 deletions

View File

@@ -25,10 +25,10 @@ describe('Header', () => {
render(<Header />); render(<Header />);
expect(screen.getByText('dk')).toBeInTheDocument(); expect(screen.getByText('dk')).toBeInTheDocument();
// Check for navigation links // Check for navigation links (appear in both desktop and mobile menus)
expect(screen.getByText('Home')).toBeInTheDocument(); expect(screen.getAllByText('Home').length).toBeGreaterThan(0);
expect(screen.getByText('About')).toBeInTheDocument(); expect(screen.getAllByText('About').length).toBeGreaterThan(0);
expect(screen.getByText('Projects')).toBeInTheDocument(); expect(screen.getAllByText('Projects').length).toBeGreaterThan(0);
expect(screen.getByText('Contact')).toBeInTheDocument(); expect(screen.getAllByText('Contact').length).toBeGreaterThan(0);
}); });
}); });

View File

@@ -1,12 +1,13 @@
import { render, screen } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import { ThemeToggle } from "@/app/components/ThemeToggle"; import { ThemeToggle } from "@/app/components/ThemeToggle";
// Mock next-themes // Mock custom ThemeProvider
jest.mock("next-themes", () => ({ jest.mock("@/app/components/ThemeProvider", () => ({
useTheme: () => ({ useTheme: () => ({
theme: "light", theme: "light",
setTheme: jest.fn(), setTheme: jest.fn(),
}), }),
ThemeProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
})); }));
describe("ThemeToggle Component", () => { describe("ThemeToggle Component", () => {

View File

@@ -29,7 +29,7 @@ export default function ClientProviders({
<ErrorBoundary> <ErrorBoundary>
<ErrorBoundary> <ErrorBoundary>
<ConsentProvider> <ConsentProvider>
<ThemeProvider attribute="class" defaultTheme="light" enableSystem={false}> <ThemeProvider>
<GatedProviders mounted={mounted}> <GatedProviders mounted={mounted}>
{children} {children}
</GatedProviders> </GatedProviders>

View File

@@ -1,7 +1,6 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { Menu, X } from "lucide-react"; import { Menu, X } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useLocale, useTranslations } from "next-intl"; import { useLocale, useTranslations } from "next-intl";
@@ -26,11 +25,7 @@ const Header = () => {
return ( return (
<> <>
<div className="fixed top-8 left-0 right-0 z-50 flex justify-center px-6 pointer-events-none"> <div className="fixed top-8 left-0 right-0 z-50 flex justify-center px-6 pointer-events-none">
<motion.nav <nav className="animate-slide-down pointer-events-auto bg-white/70 dark:bg-stone-900/70 backdrop-blur-2xl border border-white/40 dark:border-white/5 shadow-[0_8px_32px_rgba(0,0,0,0.05)] rounded-full px-3 py-2 flex items-center gap-1 md:gap-4">
initial={{ y: -100, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
className="pointer-events-auto bg-white/70 dark:bg-stone-900/70 backdrop-blur-2xl border border-white/40 dark:border-white/5 shadow-[0_8px_32px_rgba(0,0,0,0.05)] rounded-full px-3 py-2 flex items-center gap-1 md:gap-4"
>
{/* Logo Pill */} {/* Logo Pill */}
<Link <Link
href={`/${locale}`} href={`/${locale}`}
@@ -72,17 +67,16 @@ const Header = () => {
{isOpen ? <X size={14} /> : <Menu size={14} />} {isOpen ? <X size={14} /> : <Menu size={14} />}
</button> </button>
</div> </div>
</motion.nav> </nav>
</div> </div>
{/* Mobile Menu Overlay */} {/* Mobile Menu Overlay */}
<AnimatePresence> <div
{isOpen && ( className={`fixed top-24 left-6 right-6 z-40 bg-white/90 dark:bg-stone-900/95 backdrop-blur-3xl border border-white/40 dark:border-white/10 rounded-[2.5rem] shadow-2xl p-6 md:hidden overflow-hidden transition-all duration-200 ${
<motion.div isOpen
initial={{ opacity: 0, y: 10, scale: 0.98 }} ? "opacity-100 translate-y-0 pointer-events-auto"
animate={{ opacity: 1, y: 0, scale: 1 }} : "opacity-0 -translate-y-2 pointer-events-none"
exit={{ opacity: 0, y: 10, scale: 0.98 }} }`}
className="fixed top-24 left-6 right-6 z-40 bg-white/90 dark:bg-stone-900/95 backdrop-blur-3xl border border-white/40 dark:border-white/10 rounded-[2.5rem] shadow-2xl p-6 md:hidden overflow-hidden"
> >
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{navItems.map((item) => ( {navItems.map((item) => (
@@ -96,9 +90,7 @@ const Header = () => {
</Link> </Link>
))} ))}
</div> </div>
</motion.div> </div>
)}
</AnimatePresence>
</> </>
); );
}; };

View File

@@ -1,11 +1,38 @@
"use client"; "use client";
import * as React from "react"; import React, { createContext, useContext, useEffect, useState } from "react";
import { ThemeProvider as NextThemesProvider } from "next-themes";
export function ThemeProvider({ type Theme = "light" | "dark";
children,
...props const ThemeCtx = createContext<{ theme: Theme; setTheme: (t: Theme) => void }>({
}: React.ComponentProps<typeof NextThemesProvider>) { theme: "light",
return <NextThemesProvider {...props}>{children}</NextThemesProvider>; setTheme: () => {},
});
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setThemeState] = useState<Theme>("light");
useEffect(() => {
try {
const stored = localStorage.getItem("theme") as Theme | null;
if (stored === "dark" || stored === "light") {
setThemeState(stored);
document.documentElement.classList.toggle("dark", stored === "dark");
}
} catch {}
}, []);
const setTheme = (t: Theme) => {
setThemeState(t);
try {
localStorage.setItem("theme", t);
} catch {}
document.documentElement.classList.toggle("dark", t === "dark");
};
return <ThemeCtx.Provider value={{ theme, setTheme }}>{children}</ThemeCtx.Provider>;
}
export function useTheme() {
return useContext(ThemeCtx);
} }

View File

@@ -2,8 +2,7 @@
import * as React from "react"; import * as React from "react";
import { Moon, Sun } from "lucide-react"; import { Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes"; import { useTheme } from "./ThemeProvider";
import { motion } from "framer-motion";
export function ThemeToggle() { export function ThemeToggle() {
const { theme, setTheme } = useTheme(); const { theme, setTheme } = useTheme();
@@ -18,11 +17,9 @@ export function ThemeToggle() {
} }
return ( return (
<motion.button <button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={() => setTheme(theme === "dark" ? "light" : "dark")} onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
className="p-2 rounded-full bg-stone-100 dark:bg-stone-800 text-stone-800 dark:text-stone-100 hover:bg-stone-200 dark:hover:bg-stone-700 transition-colors border border-stone-200 dark:border-stone-700 shadow-sm" className="p-2 rounded-full bg-stone-100 dark:bg-stone-800 text-stone-800 dark:text-stone-100 hover:bg-stone-200 dark:hover:bg-stone-700 transition-colors border border-stone-200 dark:border-stone-700 shadow-sm hover:scale-105 active:scale-95 transition-transform"
aria-label="Toggle theme" aria-label="Toggle theme"
> >
{theme === "dark" ? ( {theme === "dark" ? (
@@ -30,6 +27,6 @@ export function ThemeToggle() {
) : ( ) : (
<Moon size={18} className="text-stone-600" /> <Moon size={18} className="text-stone-600" />
)} )}
</motion.button> </button>
); );
} }

View File

@@ -32,6 +32,8 @@ export default async function RootLayout({
<html lang={locale} suppressHydrationWarning> <html lang={locale} suppressHydrationWarning>
<head> <head>
<meta charSet="utf-8" /> <meta charSet="utf-8" />
{/* Prevent flash of unstyled theme — reads localStorage before React hydrates */}
<script dangerouslySetInnerHTML={{ __html: `try{var t=localStorage.getItem('theme');if(t==='dark')document.documentElement.classList.add('dark');}catch(e){}` }} />
</head> </head>
<body className={`${inter.variable} ${playfair.variable}`} suppressHydrationWarning> <body className={`${inter.variable} ${playfair.variable}`} suppressHydrationWarning>
<div className="grain-overlay" aria-hidden="true" /> <div className="grain-overlay" aria-hidden="true" />

View File

@@ -1,7 +1,6 @@
"use client"; "use client";
import React from "react"; import React from "react";
import { motion } from 'framer-motion';
import { ArrowLeft, Mail, MapPin, Scale, ShieldCheck, Clock } from 'lucide-react'; import { ArrowLeft, Mail, MapPin, Scale, ShieldCheck, Clock } from 'lucide-react';
import Header from "../components/Header"; import Header from "../components/Header";
import Footer from "../components/Footer"; import Footer from "../components/Footer";
@@ -35,11 +34,7 @@ export default function LegalNotice() {
<main className="max-w-7xl mx-auto px-6 pt-40 pb-20"> <main className="max-w-7xl mx-auto px-6 pt-40 pb-20">
{/* Editorial Header */} {/* Editorial Header */}
<motion.div <div className="animate-fade-in mb-20">
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
className="mb-20"
>
<Link <Link
href={`/${locale}`} href={`/${locale}`}
className="inline-flex items-center gap-2 text-stone-500 hover:text-stone-900 dark:hover:text-white transition-colors mb-10 group" className="inline-flex items-center gap-2 text-stone-500 hover:text-stone-900 dark:hover:text-white transition-colors mb-10 group"
@@ -51,18 +46,13 @@ export default function LegalNotice() {
<h1 className="text-6xl md:text-[10rem] font-black tracking-tighter text-stone-900 dark:text-stone-50 leading-[0.85] uppercase"> <h1 className="text-6xl md:text-[10rem] font-black tracking-tighter text-stone-900 dark:text-stone-50 leading-[0.85] uppercase">
Legal<span className="text-liquid-mint">.</span> Legal<span className="text-liquid-mint">.</span>
</h1> </h1>
</motion.div> </div>
{/* Bento Content Grid */} {/* Bento Content Grid */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8"> <div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
{/* Main Legal Content (Large Box) */} {/* Main Legal Content (Large Box) */}
<motion.div <div className="animate-[fadeIn_0.5s_ease-out_0.1s_both] 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">
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
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"
>
{cmsHtml ? ( {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 html={cmsHtml} /> <RichTextClient html={cmsHtml} />
@@ -90,7 +80,7 @@ export default function LegalNotice() {
</section> </section>
</div> </div>
)} )}
</motion.div> </div>
{/* Sidebar Widgets */} {/* Sidebar Widgets */}
<div className="lg:col-span-4 space-y-8"> <div className="lg:col-span-4 space-y-8">

View File

@@ -1,7 +1,6 @@
"use client"; "use client";
import React from "react"; import React from "react";
import { motion } from 'framer-motion';
import { ArrowLeft, Shield, Lock, Eye, Database, Globe } from 'lucide-react'; import { ArrowLeft, Shield, Lock, Eye, Database, Globe } from 'lucide-react';
import Header from "../components/Header"; import Header from "../components/Header";
import Footer from "../components/Footer"; import Footer from "../components/Footer";
@@ -35,11 +34,7 @@ export default function PrivacyPolicy() {
<main className="max-w-7xl mx-auto px-6 pt-40 pb-20"> <main className="max-w-7xl mx-auto px-6 pt-40 pb-20">
{/* Editorial Header */} {/* Editorial Header */}
<motion.div <div className="animate-fade-in mb-20">
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
className="mb-20"
>
<Link <Link
href={`/${locale}`} href={`/${locale}`}
className="inline-flex items-center gap-2 text-stone-500 hover:text-stone-900 dark:hover:text-white transition-colors mb-10 group" className="inline-flex items-center gap-2 text-stone-500 hover:text-stone-900 dark:hover:text-white transition-colors mb-10 group"
@@ -51,18 +46,13 @@ export default function PrivacyPolicy() {
<h1 className="text-6xl md:text-[10rem] font-black tracking-tighter text-stone-900 dark:text-stone-50 leading-[0.85] uppercase"> <h1 className="text-6xl md:text-[10rem] font-black tracking-tighter text-stone-900 dark:text-stone-50 leading-[0.85] uppercase">
Privacy<span className="text-liquid-purple">.</span> Privacy<span className="text-liquid-purple">.</span>
</h1> </h1>
</motion.div> </div>
{/* Bento Content Grid */} {/* Bento Content Grid */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8"> <div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
{/* Main Privacy Text (Large) */} {/* Main Privacy Text (Large) */}
<motion.div <div className="animate-[fadeIn_0.5s_ease-out_0.1s_both] 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">
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
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"
>
{cmsHtml ? ( {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 html={cmsHtml} /> <RichTextClient html={cmsHtml} />
@@ -88,7 +78,7 @@ export default function PrivacyPolicy() {
</section> </section>
</div> </div>
)} )}
</motion.div> </div>
{/* Quick Info Cards */} {/* Quick Info Cards */}
<div className="lg:col-span-4 space-y-8"> <div className="lg:col-span-4 space-y-8">

View File

@@ -70,7 +70,6 @@
"lucide-react": "^0.542.0", "lucide-react": "^0.542.0",
"next": "^15.5.7", "next": "^15.5.7",
"next-intl": "^4.7.0", "next-intl": "^4.7.0",
"next-themes": "^0.4.6",
"node-cache": "^5.1.2", "node-cache": "^5.1.2",
"node-fetch": "^2.7.0", "node-fetch": "^2.7.0",
"nodemailer": "^7.0.11", "nodemailer": "^7.0.11",

View File

@@ -16,9 +16,14 @@ export default {
"0%": { opacity: "0", transform: "translateY(10px)" }, "0%": { opacity: "0", transform: "translateY(10px)" },
"100%": { opacity: "1", transform: "translateY(0)" }, "100%": { opacity: "1", transform: "translateY(0)" },
}, },
slideDown: {
"0%": { opacity: "0", transform: "translateY(-20px)" },
"100%": { opacity: "1", transform: "translateY(0)" },
},
}, },
animation: { animation: {
"fade-in": "fadeIn 0.5s ease-out", "fade-in": "fadeIn 0.5s ease-out",
"slide-down": "slideDown 0.4s ease-out",
}, },
colors: { colors: {
background: "var(--background)", background: "var(--background)",