From e49dacf0574d04a910098f37e2c531a0d1cfcff8 Mon Sep 17 00:00:00 2001 From: Marco Date: Sat, 1 Aug 2026 06:17:09 +0000 Subject: [PATCH] Fix search overlay CSS trap and blog filter navigation race SearchOverlay was rendered as a
descendant, so the header's conditional backdrop-blur-md (once scrolled) made it the containing block for the overlay's fixed positioning, clipping the opaque background to the header's height and letting page content show through underneath. Now rendered as a header sibling, same pattern already used for NewsletterModal. Blog category chips are now a client component gating navigation behind useTransition, disabling the chips while a navigation is pending so rapid clicks can't fire overlapping RSC navigations that commit out of order and briefly show an empty result. --- app/blog/page.tsx | 30 +---------- app/components/BlogCategoryFilter.tsx | 44 ++++++++++++++++ app/components/Navbar.tsx | 6 ++- app/components/SearchOverlay.tsx | 73 +++++++++++++++------------ 4 files changed, 90 insertions(+), 63 deletions(-) create mode 100644 app/components/BlogCategoryFilter.tsx diff --git a/app/blog/page.tsx b/app/blog/page.tsx index 0b4fd3f..d2f2854 100644 --- a/app/blog/page.tsx +++ b/app/blog/page.tsx @@ -4,6 +4,7 @@ import Image from "next/image"; import { Reveal, RevealGroup, RevealItem } from "../components/Reveal"; import { Newsletter } from "../components/Newsletter"; import { Footer } from "../components/Footer"; +import { BlogCategoryFilter } from "../components/BlogCategoryFilter"; import { getBlogPosts, getBlogFilterEnabled } from "../lib/payload"; import { formatDate } from "../lib/format"; @@ -31,11 +32,6 @@ function distinctCategories(posts: { categories: string[] }[]): string[] { return Array.from(seen); } -function buildCategoryHref(active: string[], category: string): string { - const next = active.includes(category) ? active.filter((c) => c !== category) : [...active, category]; - return next.length > 0 ? `/blog?categories=${next.map(encodeURIComponent).join(",")}` : "/blog"; -} - export default async function BlogOverviewPage({ searchParams, }: { @@ -93,29 +89,7 @@ export default async function BlogOverviewPage({ under the description keeps them in view immediately, on every width, no scrolling past the photo needed. */} {allCategories.length > 1 && ( -
- {allCategories.map((category) => { - const active = activeCategories.includes(category); - return ( - - {category} - - ); - })} -
+ )}
diff --git a/app/components/BlogCategoryFilter.tsx b/app/components/BlogCategoryFilter.tsx new file mode 100644 index 0000000..bfcb975 --- /dev/null +++ b/app/components/BlogCategoryFilter.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { useTransition } from "react"; +import { useRouter } from "next/navigation"; + +function buildCategoryHref(active: string[], category: string): string { + const next = active.includes(category) ? active.filter((c) => c !== category) : [...active, category]; + return next.length > 0 ? `/blog?categories=${next.map(encodeURIComponent).join(",")}` : "/blog"; +} + +// Client-side navigation (not a plain ) specifically so `isPending` +// can gate the chips: rapid clicks used to fire multiple concurrent RSC +// navigations with no guarantee they'd commit in the order they were +// requested, so a slower, stale navigation could land after a newer one and +// briefly show the wrong (sometimes empty) result. Disabling the chips +// while a navigation is in flight makes that ordering race impossible — +// only one navigation can ever be outstanding at a time. +export function BlogCategoryFilter({ allCategories, activeCategories }: { allCategories: string[]; activeCategories: string[] }) { + const router = useRouter(); + const [isPending, startTransition] = useTransition(); + + return ( +
+ {allCategories.map((category) => { + const active = activeCategories.includes(category); + return ( + + ); + })} +
+ ); +} diff --git a/app/components/Navbar.tsx b/app/components/Navbar.tsx index d3f706a..b35da16 100644 --- a/app/components/Navbar.tsx +++ b/app/components/Navbar.tsx @@ -7,7 +7,7 @@ import { usePathname } from "next/navigation"; import { AnimatePresence, motion } from "motion/react"; import { useCartCount } from "../lib/cart"; import { useWishlist } from "../lib/useWishlist"; -import { SearchButton } from "./SearchOverlay"; +import { SearchButton, SearchOverlay } from "./SearchOverlay"; import { AUTH_CHANGED_EVENT } from "../lib/auth"; import { NewsletterModal } from "./NewsletterModal"; import { useCartFly } from "./CartFly"; @@ -302,6 +302,7 @@ export function Navbar({ const [activeSection, setActiveSection] = useState(""); const [mobileOpen, setMobileOpen] = useState(false); const [newsletterOpen, setNewsletterOpen] = useState(false); + const [searchOpen, setSearchOpen] = useState(false); const panelRef = useRef(null); const hamburgerRef = useRef(null); @@ -619,7 +620,7 @@ export function Navbar({ separates this group from the CTA-buttons/hamburger group that follows. */}
- {searchEnabled && } + {searchEnabled && setSearchOpen(true)} />} {wishlistEnabled && } @@ -813,6 +814,7 @@ export function Navbar({ setNewsletterOpen(false)} /> + {searchEnabled && setSearchOpen(false)} />} ); } diff --git a/app/components/SearchOverlay.tsx b/app/components/SearchOverlay.tsx index 070f013..d206e12 100644 --- a/app/components/SearchOverlay.tsx +++ b/app/components/SearchOverlay.tsx @@ -7,41 +7,33 @@ import type { SearchResult } from "../api/search/route"; const DEBOUNCE_MS = 250; -export function SearchButton() { - const [open, setOpen] = useState(false); - - useEffect(() => { - if (!open) return; - function onKeyDown(e: KeyboardEvent) { - if (e.key === "Escape") setOpen(false); - } - document.addEventListener("keydown", onKeyDown); - document.body.style.overflow = "hidden"; - return () => { - document.removeEventListener("keydown", onKeyDown); - document.body.style.overflow = ""; - }; - }, [open]); - +// Plain trigger button — no state of its own. `open`/`onOpen` are lifted to +// Navbar (mirrors NewsletterModal's pattern) so SearchOverlay itself can be +// rendered as a
*sibling* instead of a descendant. Rendering it +// inside
put it under the header's conditional `backdrop-blur-md` +// (applied once `scrolled` or `mobileOpen` is true), and per spec a +// `backdrop-filter` makes its element a new containing block for +// `position: fixed` descendants — the overlay's `fixed inset-0` then +// resolved against the ~100px header instead of the viewport, clipping its +// opaque background to that band while the input/results overflowed past +// it, letting the page content underneath show through. +export function SearchButton({ onOpen }: { onOpen: () => void }) { return ( - <> - - {open && setOpen(false)} />} - + ); } -function SearchOverlay({ onClose }: { onClose: () => void }) { +export function SearchOverlay({ open, onClose }: { open: boolean; onClose: () => void }) { const [query, setQuery] = useState(""); const [results, setResults] = useState([]); const [loading, setLoading] = useState(false); @@ -49,8 +41,21 @@ function SearchOverlay({ onClose }: { onClose: () => void }) { const debounceRef = useRef | null>(null); useEffect(() => { - inputRef.current?.focus(); - }, []); + if (!open) return; + function onKeyDown(e: KeyboardEvent) { + if (e.key === "Escape") onClose(); + } + document.addEventListener("keydown", onKeyDown); + document.body.style.overflow = "hidden"; + return () => { + document.removeEventListener("keydown", onKeyDown); + document.body.style.overflow = ""; + }; + }, [open, onClose]); + + useEffect(() => { + if (open) inputRef.current?.focus(); + }, [open]); useEffect(() => { if (debounceRef.current) clearTimeout(debounceRef.current); @@ -74,6 +79,8 @@ function SearchOverlay({ onClose }: { onClose: () => void }) { const products = results.filter((r) => r.type === "product"); const posts = results.filter((r) => r.type === "post"); + if (!open) return null; + return (
e.stopPropagation()}>