Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 24 additions & 15 deletions components/site/MobileMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
useSyncExternalStore,
} from "react";
import { createPortal } from "react-dom";
import { useRouter } from "next/navigation";
import { links } from "@/lib/links";
import { HOME_SECTION_ID, scrollToAnchor } from "@/lib/scroll";

Expand Down Expand Up @@ -51,6 +52,7 @@ const getServerSnapshot = () => false;

export function MobileMenu({ activeSection, tone, onOpenChange }: Props) {
const [open, setOpen] = useState(false);
const router = useRouter();
// Static export: document.body isn't available during prerender. Gate
// the portal until after hydration so the first client render matches
// the prerendered HTML.
Expand All @@ -75,13 +77,15 @@ export function MobileMenu({ activeSection, tone, onOpenChange }: Props) {
} | null>(null);
const scrollYRef = useRef(0);
// When the drawer closes via an in-page anchor tap, the cleanup must
// scroll to that target instead of restoring the prior scroll
// position — otherwise the body unlock would override the anchor jump.
// scroll (or, off-route, soft-nav) to that target instead of restoring
// the prior scroll position — otherwise the body unlock would override
// the anchor jump.
const pendingAnchorRef = useRef<string | null>(null);
// An anchor-select close moves focus into the target section from the
// Effect-A cleanup, which runs *before* the toggle-refocus effect's
// setup in the same commit. This flag — set there, consumed there —
// stops that effect from yanking focus back to the hamburger.
// An in-page anchor-select close (target in this DOM) moves focus into
// the target section from the scroll-lock effect's cleanup, which runs
// *before* the toggle-refocus effect's setup in the same commit. This
// flag — set there, consumed there — stops that effect from yanking
// focus back to the hamburger.
const focusMovedToSectionRef = useRef(false);

useEffect(() => {
Expand Down Expand Up @@ -169,12 +173,16 @@ export function MobileMenu({ activeSection, tone, onOpenChange }: Props) {
const targetEl = anchor ? document.getElementById(anchor) : null;

if (anchor && !targetEl) {
// Drawer opened from a different route (e.g. /blog/*); a full
// reload lets the new page resolve the destination deterministically
// (Next 16 App Router's soft-nav hash behaviour under output:
// "export" isn't documented). Home -> "/", section -> "/#anchor".
// Skip the local restore — the page is about to unload.
window.location.assign(isHome ? "/" : `/#${anchor}`);
// Drawer opened from a different route (e.g. /blog/*), so the
// section isn't in this DOM: soft-nav home and let the router
// scroll to the hash. Home -> "/", section -> "/#anchor". Must
// run after the body unlock above — the router's hash scroll is a
// no-op against a still-pinned body. Historically a full reload,
// because soft-nav hash handling under output: "export" was
// untrusted; its one concrete failure, `/#id#id` (#116), was fixed
// upstream in Next 16.3.8. Skip the local restore and focus move —
// the router owns scroll and the URL.
router.push(isHome ? "/" : `/#${anchor}`);
return;
}

Expand Down Expand Up @@ -217,16 +225,17 @@ export function MobileMenu({ activeSection, tone, onOpenChange }: Props) {
focusMovedToSectionRef.current = true;
}
};
}, [open]);
}, [open, router]);

// Skipped on the initial render (when `open` is already false) via
// the wasOpenRef guard.
const wasOpenRef = useRef(false);
useEffect(() => {
if (wasOpenRef.current && !open) {
// An anchor-select close already moved focus into the section;
// leave it there. Normal closes (Escape, scrim, toggle, resize)
// leave the flag false and still return focus to the toggle.
// leave it there. Normal closes (Escape, scrim, toggle, resize) and
// an off-route anchor close (soft-navs home) leave the flag false
// and still return focus to the toggle.
if (focusMovedToSectionRef.current) {
focusMovedToSectionRef.current = false;
} else {
Expand Down
Loading