diff --git a/apps/website/app/layout.tsx b/apps/website/app/layout.tsx index 4053b3cd..23cec686 100644 --- a/apps/website/app/layout.tsx +++ b/apps/website/app/layout.tsx @@ -70,7 +70,7 @@ export default function RootLayout({ the dim layer, but remove backdrop filtering: re-blurring a live WebGL canvas throughout the sheet animation is prohibitively expensive on mobile GPUs. */ - "[&_[data-slot=sheet-overlay]]:backdrop-filter-none", + "**:data-[slot=sheet-overlay]:backdrop-filter-none", )} > {/* `href` + `precedence` is what gets React to hoist this into . diff --git a/apps/website/components/Nav.tsx b/apps/website/components/Nav.tsx index 8847755e..28331cf3 100644 --- a/apps/website/components/Nav.tsx +++ b/apps/website/components/Nav.tsx @@ -12,9 +12,12 @@ import { useMemo, useRef, useState, + useSyncExternalStore, } from "react"; import { useParams } from "next/navigation"; import { createSerializer, parseAsString, useQueryStates } from "nuqs"; +import { type Options, useHotkeys } from "react-hotkeys-hook"; +import { useEventListener, useIsClient } from "usehooks-ts"; import { ChevronLeftIcon, ListFilterIcon, @@ -84,6 +87,24 @@ const filterParsers = { survives the client navigation into an example. */ const serializeFilters = createSerializer(filterParsers); +/* `data-nav-collapsed` is put on before first paint by the inline + script in `app/layout.tsx` — the only way a statically exported page can + weigh `localStorage`, the `?nav=` override and the current route that early. + React reads the verdict back through `useSyncExternalStore` rather than from + a mount effect: the value is already there at hydration, and the server + snapshot is what keeps the first render agreeing with the prerendered HTML. + + The read is cached because React hands the attribute back as soon as the + panel is its own (see the effect below); a later read would report a rail + that is open no matter how the page was painted. */ +let paintedCollapsed: boolean | undefined; + +const subscribeToPaintedCollapsed = () => () => {}; +const getPaintedCollapsed = () => + (paintedCollapsed ??= + document.documentElement.hasAttribute("data-nav-collapsed")); +const getPaintedCollapsedOnServer = () => false; + /** * Keep the list itself complete for links, roving focus and stable scroll * geometry, but only mount expensive thumbnail/tag subtrees near the visible @@ -98,28 +119,20 @@ function useNearbyExamples( const [nearby, setNearby] = useState>(() => new Set()); useLayoutEffect(() => { - if (!list) { - setNearby((current) => (current.size === 0 ? current : new Set())); - return; - } + /* No list, nothing rendered from `nearby`: leaving the old set alone + spares a render, and the seed below replaces it wholesale anyway. */ + if (!list) return; const items = Array.from( list.querySelectorAll("[data-example]"), ); - if (!("IntersectionObserver" in window)) { - setNearby( - new Set( - items.flatMap((item) => - item.dataset.example ? [item.dataset.example] : [], - ), - ), - ); - return; - } - + const observable = "IntersectionObserver" in window; const root = list.closest("[data-slot='sidebar-content']"); - const rootRect = root?.getBoundingClientRect(); + const rootRect = observable ? root?.getBoundingClientRect() : undefined; + /* An unbounded window is also the answer for a browser without + IntersectionObserver: nothing will narrow it later, so every card + counts as nearby and the seed below is the whole of the fallback. */ const nearTop = rootRect ? rootRect.top - rootRect.height / 2 : -Infinity; const nearBottom = rootRect ? rootRect.bottom + rootRect.height / 2 @@ -127,7 +140,13 @@ function useNearbyExamples( /* IntersectionObserver reports after layout, which leaves a blank frame when filtering moves an unmounted thumbnail into view. Seed the same - 50% margin synchronously so the next render is ready before paint. */ + 50% margin synchronously so the next render is ready before paint. + + This is the measure-then-render pass `useLayoutEffect` exists for, and + the one shape `set-state-in-effect` cannot tell apart from a cascading + render; the observer below, where this state spends the rest of its + life, is the subscription the rule asks for. */ + // eslint-disable-next-line react-hooks/set-state-in-effect -- measured layout, see above setNearby( new Set( items.flatMap((item) => { @@ -141,6 +160,8 @@ function useNearbyExamples( ), ); + if (!observable) return; + let active = true; const observer = new IntersectionObserver( (entries) => { @@ -208,27 +229,21 @@ function NavToggle() { right one; the label has to agree with it. */ const shown = isMobile ? openMobile : open; - const [ready, setReady] = useState(false); + const ready = useIsClient(); const [near, setNear] = useState(false); - useEffect(() => setReady(true), []); - useEffect(() => { if (isMobile) setOpenMobile(!examplename); }, [examplename, isMobile, setOpenMobile]); /* Collapsed, the pill mostly tucks itself into the page edge; bringing the - pointer over there nudges it back out. */ - useEffect(() => { - if (shown) { - setNear(false); - return; - } - - const onMove = (event: MouseEvent) => setNear(event.clientX < 120); - window.addEventListener("mousemove", onMove); - return () => window.removeEventListener("mousemove", onMove); - }, [shown]); + pointer over there nudges it back out. Expanded, the proximity is moot — + the class list below never reaches `near` — so the listener stays put and + simply reports `false`, which keeps the state honest for the next + collapse without a second effect to reset it. */ + useEventListener("mousemove", (event) => + setNear(!shown && event.clientX < 120), + ); return (