fix(i18n): render locale switch as links

Use locale-prefixed <Link> elements for EN/DE so language switching works even when client-side hydration is broken.

Co-authored-by: dennis <dennis@konkol.net>
This commit is contained in:
Cursor Agent
2026-01-14 16:29:55 +00:00
parent 411806d5ce
commit f2b3f1edfd
2 changed files with 17 additions and 31 deletions

View File

@@ -6,14 +6,14 @@ import { Menu, X, Mail } from "lucide-react";
import { SiGithub, SiLinkedin } from "react-icons/si"; import { SiGithub, SiLinkedin } from "react-icons/si";
import Link from "next/link"; import Link from "next/link";
import { useLocale, useTranslations } from "next-intl"; import { useLocale, useTranslations } from "next-intl";
import { usePathname, useRouter } from "next/navigation"; import { usePathname, useSearchParams } from "next/navigation";
const Header = () => { const Header = () => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [scrolled, setScrolled] = useState(false); const [scrolled, setScrolled] = useState(false);
const locale = useLocale(); const locale = useLocale();
const pathname = usePathname(); const pathname = usePathname();
const router = useRouter(); const searchParams = useSearchParams();
const t = useTranslations("nav"); const t = useTranslations("nav");
const isHome = pathname === `/${locale}` || pathname === `/${locale}/`; const isHome = pathname === `/${locale}` || pathname === `/${locale}/`;
@@ -44,23 +44,11 @@ const Header = () => {
{ icon: Mail, href: "mailto:contact@dk0.dev", label: "Email" }, { icon: Mail, href: "mailto:contact@dk0.dev", label: "Email" },
]; ];
const switchLocale = (nextLocale: string) => {
try {
const pathWithoutLocale = pathname.replace(new RegExp(`^/${locale}`), "") || ""; const pathWithoutLocale = pathname.replace(new RegExp(`^/${locale}`), "") || "";
const hash = typeof window !== "undefined" ? window.location.hash : ""; const qs = searchParams.toString();
// Rely on middleware to persist NEXT_LOCALE cookie. const query = qs ? `?${qs}` : "";
// Use a hard navigation for maximum reliability (also fixes cases where const enHref = `/en${pathWithoutLocale}${query}`;
// client-side router navigation can be prevented by runtime errors). const deHref = `/de${pathWithoutLocale}${query}`;
const target = `/${nextLocale}${pathWithoutLocale}${hash}`;
if (typeof window !== "undefined") {
window.location.assign(target);
return;
}
router.push(target);
} catch {
// ignore
}
};
// Always render to prevent flash, but use opacity transition // Always render to prevent flash, but use opacity transition
@@ -144,9 +132,8 @@ const Header = () => {
<div className="hidden md:flex items-center space-x-3"> <div className="hidden md:flex items-center space-x-3">
<div className="flex items-center bg-white/40 border border-white/50 rounded-full overflow-hidden shadow-sm"> <div className="flex items-center bg-white/40 border border-white/50 rounded-full overflow-hidden shadow-sm">
<button <Link
type="button" href={enHref}
onClick={() => switchLocale("en")}
className={`px-3 py-1.5 text-xs font-semibold transition-colors ${ className={`px-3 py-1.5 text-xs font-semibold transition-colors ${
locale === "en" locale === "en"
? "bg-stone-900 text-stone-50" ? "bg-stone-900 text-stone-50"
@@ -155,10 +142,9 @@ const Header = () => {
aria-label="Switch language to English" aria-label="Switch language to English"
> >
EN EN
</button> </Link>
<button <Link
type="button" href={deHref}
onClick={() => switchLocale("de")}
className={`px-3 py-1.5 text-xs font-semibold transition-colors ${ className={`px-3 py-1.5 text-xs font-semibold transition-colors ${
locale === "de" locale === "de"
? "bg-stone-900 text-stone-50" ? "bg-stone-900 text-stone-50"
@@ -167,7 +153,7 @@ const Header = () => {
aria-label="Sprache auf Deutsch umstellen" aria-label="Sprache auf Deutsch umstellen"
> >
DE DE
</button> </Link>
</div> </div>
{socialLinks.map((social) => ( {socialLinks.map((social) => (
<motion.a <motion.a

View File

@@ -4,15 +4,15 @@ test.describe("i18n routing", () => {
test("language switcher navigates between locales", async ({ page }) => { test("language switcher navigates between locales", async ({ page }) => {
await page.goto("/en", { waitUntil: "domcontentloaded" }); await page.goto("/en", { waitUntil: "domcontentloaded" });
// Buttons have aria-labels; click the DE switcher. // Locale switchers are links (work even without hydration)
const deButton = page.getByRole("button", { name: "Sprache auf Deutsch umstellen" }); const deLink = page.getByRole("link", { name: "Sprache auf Deutsch umstellen" });
if (await deButton.count()) { if (await deLink.count()) {
// Verify an EN label is present before switching (nav.home) // Verify an EN label is present before switching (nav.home)
await expect(page.getByRole("link", { name: "Home" })).toBeVisible(); await expect(page.getByRole("link", { name: "Home" })).toBeVisible();
await Promise.all([ await Promise.all([
page.waitForURL(/\/de(\/|$)/, { timeout: 30000 }), page.waitForURL(/\/de(\/|$)/, { timeout: 30000 }),
deButton.click(), deLink.click(),
]); ]);
// Verify the nav label updates after switching // Verify the nav label updates after switching