diff --git a/frontend/src/components/PageShell.dom.test.tsx b/frontend/src/components/PageShell.dom.test.tsx index 8b526bc4..5d1f2a78 100644 --- a/frontend/src/components/PageShell.dom.test.tsx +++ b/frontend/src/components/PageShell.dom.test.tsx @@ -50,4 +50,27 @@ describe("PageShell --hdr-h (E5 S7)", () => { const { container } = render(content); expect(container.querySelector('[class*="pt-(--hdr-h)"]')).toBeNull(); }); + + it("re-measures when the header is removed then restored (feed→channel→back)", () => { + // PageShell is REUSED across feed↔channel navigation (same component position in App.tsx) — it + // never remounts, so the header DOM node is swapped under a stable PageShell. The measurement + // must re-run on that swap; a one-shot effect kept observing the FIRST node and, when it detached, + // rewrote --hdr-h to a stale 8px that stuck on Back. Regression for the channel-Back header overlap. + const hdr = () =>
H
; + const { container, rerender } = render(content); + const measure = (top: number, h: number) => { + const el = container.querySelector('[data-testid="hdr"]') as HTMLElement; + Object.defineProperty(el, "offsetTop", { value: top, configurable: true }); + Object.defineProperty(el, "offsetHeight", { value: h, configurable: true }); + roCb?.(); + }; + measure(12, 52); + const content = () => container.querySelector('[class*="pt-(--hdr-h)"]') as HTMLElement; + expect(content().style.getPropertyValue("--hdr-h")).toBe("72px"); // 12 + 52 + 8 + + rerender(content); // → channel page: header gone + rerender(content); // → Back: header returns as a NEW node + measure(12, 52); // fires the NEW header's observer + expect(content().style.getPropertyValue("--hdr-h")).toBe("72px"); // re-measured, not the stale 8px + }); }); diff --git a/frontend/src/components/PageShell.tsx b/frontend/src/components/PageShell.tsx index 98d302a7..3e58fe5f 100644 --- a/frontend/src/components/PageShell.tsx +++ b/frontend/src/components/PageShell.tsx @@ -4,7 +4,6 @@ import { isValidElement, useContext, useLayoutEffect, - useRef, useState, type ReactElement, type ReactNode, @@ -65,35 +64,42 @@ export default function PageShell({ const [toolbarSlot, setToolbarSlot] = useState(null); // The floating header pads the content below it by `--hdr-h`. That must equal the header's REAL // height, which grows as the flex-wrap header wraps to 2–3 rows on a narrow window — a fixed pad - // would let the content toolbar slide up under it (E5 S7). Measure the header (a ref forwarded onto - // its root box) and write its height straight onto the content wrapper that consumes `--hdr-h`, so - // the two live together with no assumption about the header's DOM parent. - const headerRef = useRef(null); - const contentRef = useRef(null); + // would let the content toolbar slide up under it (E5 S7). Measure the header and write its height + // straight onto the content wrapper that consumes `--hdr-h`. + // + // Both nodes are held in STATE (callback refs), NOT plain refs behind a one-shot `useLayoutEffect([])`: + // PageShell is reused — never remounted — across feed↔channel navigation (same component position in + // App.tsx), so the header DOM node is swapped out from under a stable PageShell. A one-shot effect keeps + // observing the FIRST header node; when that node detaches on channel-open, its ResizeObserver fires with + // a 0 box and rewrites --hdr-h to `0+0+8`=8px on the (still-mounted) content div. On Back the pad class + // returns with that stale 8px → the content toolbar slides up under the header, stuck until reload. Keying + // the measurement to the live node re-runs it every time the header mounts/unmounts, so it stays correct. + const [headerEl, setHeaderEl] = useState(null); + const [contentEl, setContentEl] = useState(null); useLayoutEffect(() => { - const h = headerRef.current; - const c = contentRef.current; - if (!h || !c) return; + if (!headerEl || !contentEl) { + // No header (channel page) → drop any stale pad so a leftover value can't linger. + contentEl?.style.removeProperty("--hdr-h"); + return; + } // `+ 8` = the header's own gap-y-2 row gap (0.5rem), reused as the gap below it. - const apply = () => c.style.setProperty("--hdr-h", `${h.offsetTop + h.offsetHeight + 8}px`); + const apply = () => + contentEl.style.setProperty("--hdr-h", `${headerEl.offsetTop + headerEl.offsetHeight + 8}px`); apply(); const ro = new ResizeObserver(apply); - ro.observe(h); - return () => { - ro.disconnect(); - c.style.removeProperty("--hdr-h"); - }; - }, []); + ro.observe(headerEl); + return () => ro.disconnect(); + }, [headerEl, contentEl]); const measuredHeader = isValidElement(header) ? cloneElement(header as ReactElement<{ ref?: Ref }>, { - ref: headerRef, + ref: setHeaderEl, }) : header; return ( {measuredHeader}
{banners}