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}