fix(feed): re-measure header on feed↔channel Back so --hdr-h can't stick at 8px
PageShell is reused (never remounted) across feed↔channel navigation — same component position in App.tsx — so a one-shot useLayoutEffect kept its ResizeObserver bound to the FIRST header node. On channel-open that node detached and fired the observer with a 0 box, rewriting --hdr-h to 0+0+8=8px on the still-mounted content div. Back restored the pt-(--hdr-h) pad class with that stale value, sliding the content toolbar up under the header — stuck until reload. Hold both the header and content nodes in state (callback refs) and key the measurement effect to the live nodes, so it re-runs every time the header mounts/unmounts; drop the stale pad when the header is absent (channel page). Adds a regression test: header removed then restored must re-measure, not 8px.
This commit is contained in:
@@ -50,4 +50,27 @@ describe("PageShell --hdr-h (E5 S7)", () => {
|
||||
const { container } = render(<PageShell>content</PageShell>);
|
||||
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 = () => <div data-testid="hdr">H</div>;
|
||||
const { container, rerender } = render(<PageShell header={hdr()}>content</PageShell>);
|
||||
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(<PageShell>content</PageShell>); // → channel page: header gone
|
||||
rerender(<PageShell header={hdr()}>content</PageShell>); // → 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
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<HTMLDivElement | null>(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<HTMLDivElement | null>(null);
|
||||
const contentRef = useRef<HTMLDivElement | null>(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<HTMLDivElement | null>(null);
|
||||
const [contentEl, setContentEl] = useState<HTMLDivElement | null>(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<HTMLDivElement> }>, {
|
||||
ref: headerRef,
|
||||
ref: setHeaderEl,
|
||||
})
|
||||
: header;
|
||||
return (
|
||||
<PageToolbarSlot.Provider value={toolbarSlot}>
|
||||
{measuredHeader}
|
||||
<div
|
||||
ref={contentRef}
|
||||
ref={setContentEl}
|
||||
className={`flex-1 min-w-0 min-h-0 flex flex-col ${header ? "pt-(--hdr-h)" : ""}`}
|
||||
>
|
||||
{banners}
|
||||
|
||||
Reference in New Issue
Block a user