From dacec1895636d3e07b71dbf216b4972edcabfe14 Mon Sep 17 00:00:00 2001 From: denshooter Date: Fri, 6 Mar 2026 17:39:29 +0100 Subject: [PATCH] perf: eliminate next-themes and framer-motion from initial JS bundle - 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 --- app/__tests__/components/Header.test.tsx | 10 ++-- app/__tests__/components/ThemeToggle.test.tsx | 5 +- app/components/ClientProviders.tsx | 2 +- app/components/Header.tsx | 54 ++++++++----------- app/components/ThemeProvider.tsx | 41 +++++++++++--- app/components/ThemeToggle.tsx | 11 ++-- app/layout.tsx | 2 + app/legal-notice/page.tsx | 26 +++------ app/privacy-policy/page.tsx | 28 ++++------ package.json | 1 - tailwind.config.ts | 5 ++ 11 files changed, 94 insertions(+), 91 deletions(-) diff --git a/app/__tests__/components/Header.test.tsx b/app/__tests__/components/Header.test.tsx index 64ab5e8..1855e36 100644 --- a/app/__tests__/components/Header.test.tsx +++ b/app/__tests__/components/Header.test.tsx @@ -25,10 +25,10 @@ describe('Header', () => { render(
); expect(screen.getByText('dk')).toBeInTheDocument(); - // Check for navigation links - expect(screen.getByText('Home')).toBeInTheDocument(); - expect(screen.getByText('About')).toBeInTheDocument(); - expect(screen.getByText('Projects')).toBeInTheDocument(); - expect(screen.getByText('Contact')).toBeInTheDocument(); + // Check for navigation links (appear in both desktop and mobile menus) + expect(screen.getAllByText('Home').length).toBeGreaterThan(0); + expect(screen.getAllByText('About').length).toBeGreaterThan(0); + expect(screen.getAllByText('Projects').length).toBeGreaterThan(0); + expect(screen.getAllByText('Contact').length).toBeGreaterThan(0); }); }); diff --git a/app/__tests__/components/ThemeToggle.test.tsx b/app/__tests__/components/ThemeToggle.test.tsx index 0b16990..ad5a92c 100644 --- a/app/__tests__/components/ThemeToggle.test.tsx +++ b/app/__tests__/components/ThemeToggle.test.tsx @@ -1,12 +1,13 @@ import { render, screen } from "@testing-library/react"; import { ThemeToggle } from "@/app/components/ThemeToggle"; -// Mock next-themes -jest.mock("next-themes", () => ({ +// Mock custom ThemeProvider +jest.mock("@/app/components/ThemeProvider", () => ({ useTheme: () => ({ theme: "light", setTheme: jest.fn(), }), + ThemeProvider: ({ children }: { children: React.ReactNode }) => <>{children}, })); describe("ThemeToggle Component", () => { diff --git a/app/components/ClientProviders.tsx b/app/components/ClientProviders.tsx index 11d06a1..7aaa57a 100644 --- a/app/components/ClientProviders.tsx +++ b/app/components/ClientProviders.tsx @@ -29,7 +29,7 @@ export default function ClientProviders({ - + {children} diff --git a/app/components/Header.tsx b/app/components/Header.tsx index 0cdfdee..607239c 100644 --- a/app/components/Header.tsx +++ b/app/components/Header.tsx @@ -1,7 +1,6 @@ "use client"; import { useState } from "react"; -import { motion, AnimatePresence } from "framer-motion"; import { Menu, X } from "lucide-react"; import Link from "next/link"; import { useLocale, useTranslations } from "next-intl"; @@ -26,11 +25,7 @@ const Header = () => { return ( <>
- +
- + {/* Mobile Menu Overlay */} - - {isOpen && ( - -
- {navItems.map((item) => ( - setIsOpen(false)} - className="px-6 py-4 text-sm font-black uppercase tracking-[0.2em] text-stone-900 dark:text-stone-100 bg-stone-50 dark:bg-white/5 rounded-2xl transition-colors hover:bg-liquid-mint/10" - > - {item.name} - - ))} -
-
- )} -
+
+
+ {navItems.map((item) => ( + setIsOpen(false)} + className="px-6 py-4 text-sm font-black uppercase tracking-[0.2em] text-stone-900 dark:text-stone-100 bg-stone-50 dark:bg-white/5 rounded-2xl transition-colors hover:bg-liquid-mint/10" + > + {item.name} + + ))} +
+
); }; diff --git a/app/components/ThemeProvider.tsx b/app/components/ThemeProvider.tsx index 189a2b1..eef9dac 100644 --- a/app/components/ThemeProvider.tsx +++ b/app/components/ThemeProvider.tsx @@ -1,11 +1,38 @@ "use client"; -import * as React from "react"; -import { ThemeProvider as NextThemesProvider } from "next-themes"; +import React, { createContext, useContext, useEffect, useState } from "react"; -export function ThemeProvider({ - children, - ...props -}: React.ComponentProps) { - return {children}; +type Theme = "light" | "dark"; + +const ThemeCtx = createContext<{ theme: Theme; setTheme: (t: Theme) => void }>({ + theme: "light", + setTheme: () => {}, +}); + +export function ThemeProvider({ children }: { children: React.ReactNode }) { + const [theme, setThemeState] = useState("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 {children}; +} + +export function useTheme() { + return useContext(ThemeCtx); } diff --git a/app/components/ThemeToggle.tsx b/app/components/ThemeToggle.tsx index 0d61707..7f096d7 100644 --- a/app/components/ThemeToggle.tsx +++ b/app/components/ThemeToggle.tsx @@ -2,8 +2,7 @@ import * as React from "react"; import { Moon, Sun } from "lucide-react"; -import { useTheme } from "next-themes"; -import { motion } from "framer-motion"; +import { useTheme } from "./ThemeProvider"; export function ThemeToggle() { const { theme, setTheme } = useTheme(); @@ -18,11 +17,9 @@ export function ThemeToggle() { } return ( - 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" > {theme === "dark" ? ( @@ -30,6 +27,6 @@ export function ThemeToggle() { ) : ( )} - + ); } diff --git a/app/layout.tsx b/app/layout.tsx index 67ab29f..4c16aae 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -32,6 +32,8 @@ export default async function RootLayout({ + {/* Prevent flash of unstyled theme — reads localStorage before React hydrates */} +