diff --git a/frontend/src/components/ui/PageShell.header.dom.test.tsx b/frontend/src/components/ui/PageShell.header.dom.test.tsx
index fa2b8a66..d0852357 100644
--- a/frontend/src/components/ui/PageShell.header.dom.test.tsx
+++ b/frontend/src/components/ui/PageShell.header.dom.test.tsx
@@ -89,6 +89,38 @@ describe("the header slot", () => {
expect(screen.getByTestId("hdr").textContent).toBe("search");
});
+ it("pads the content by the filled header before paint, not when the observer catches up", () => {
+ // jsdom does no layout: the header's height here is one 40px row per pill in it. The observer
+ // never fires in this test, so only a measurement made as the pills arrive can see them.
+ const height = (el: HTMLElement) => 40 * el.childElementCount;
+ const page = (own: boolean) => (
+
+ {own && (
+
+ search
+ scope
+
+ )}
+ {fallback}
+
+ );
+ const { rerender, container } = render({null});
+ Object.defineProperty(screen.getByTestId("hdr"), "offsetHeight", {
+ get() {
+ return height(this as HTMLElement);
+ },
+ configurable: true,
+ });
+ const pad = () =>
+ (container.querySelector('[class*="pt-(--hdr-h)"]') as HTMLElement).style.getPropertyValue(
+ "--hdr-h",
+ );
+ rerender(page(false));
+ expect(pad()).toBe("44px"); // the default alone: one row
+ rerender(page(true));
+ expect(pad()).toBe("84px"); // the page's two pills, the default gone
+ });
+
it("renders a page's pills nowhere in a shell without a header", () => {
render(
diff --git a/frontend/src/components/ui/PageShell.tsx b/frontend/src/components/ui/PageShell.tsx
index 3986f971..9c8e391b 100644
--- a/frontend/src/components/ui/PageShell.tsx
+++ b/frontend/src/components/ui/PageShell.tsx
@@ -46,7 +46,13 @@ export function PageHeader({ children }: { children: ReactNode }) {
// that its own controls would replace a moment later.
export function PageHeaderDefault({ children }: { children: ReactNode }) {
const slot = useContext(PageHeaderSlot);
- return slot?.target && !slot.claimed ? createPortal(children, slot.target) : null;
+ const shown = !!slot?.target && !slot.claimed;
+ const showDefault = slot?.showDefault;
+ useLayoutEffect(() => {
+ showDefault?.(shown);
+ return () => showDefault?.(false);
+ }, [shown, showDefault]);
+ return shown && slot?.target ? createPortal(children, slot.target) : null;
}
// The page shell: the fixed chrome around a single scrollable content region. It formalizes "what
@@ -144,6 +150,20 @@ export default function PageShell({
// 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);
+ // How many PageHeaders fill the header right now: a count, not a flag, so of two on one page the one
+ // that leaves releases only its own claim.
+ const [headerClaims, setHeaderClaims] = useState(0);
+ const claimHeader = useCallback(() => {
+ setHeaderClaims((n) => n + 1);
+ return () => setHeaderClaims((n) => n - 1);
+ }, []);
+ // Whether the shell's default is in the header (PageHeaderDefault reports it).
+ const [defaultShown, setDefaultShown] = useState(false);
+ // The pills arrive by portal AFTER this shell mounted (with the page's chunk), so the first measurement
+ // saw an empty header; the ResizeObserver catches up only a frame or more later, and until then the
+ // content sat under the pills (the feed toolbar jumped 55px on a tablet). So the measurement also re-runs
+ // here, before paint, whenever what fills the header changes; the observer still covers a change within
+ // a page's own pills (Plex's view switch appearing).
useLayoutEffect(() => {
if (!headerEl || !contentEl) {
// No header (channel page) → drop any stale pad so a leftover value can't linger.
@@ -157,18 +177,12 @@ export default function PageShell({
const ro = new ResizeObserver(apply);
ro.observe(headerEl);
return () => ro.disconnect();
- }, [headerEl, contentEl]);
- // How many PageHeaders fill the header right now: a count, not a flag, so of two on one page the one
- // that leaves releases only its own claim.
- const [headerClaims, setHeaderClaims] = useState(0);
- const claimHeader = useCallback(() => {
- setHeaderClaims((n) => n + 1);
- return () => setHeaderClaims((n) => n - 1);
- }, []);
+ }, [headerEl, contentEl, headerClaims, defaultShown]);
const headerSlot = {
target: headerEl,
claimed: headerClaims > 0,
claim: claimHeader,
+ showDefault: setDefaultShown,
};
// How many views below render their own page `h1` right now (see ./pageHeading).
const [ownHeadings, setOwnHeadings] = useState(0);
diff --git a/frontend/src/components/ui/pageHeaderSlot.ts b/frontend/src/components/ui/pageHeaderSlot.ts
index e4308c2c..6b15cb84 100644
--- a/frontend/src/components/ui/pageHeaderSlot.ts
+++ b/frontend/src/components/ui/pageHeaderSlot.ts
@@ -1,14 +1,17 @@
import { createContext } from "react";
// The floating header's slot (see PageHeader in PageShell): the node a page portals its header
-// controls into, whether a page has claimed it, and how a page claims it. Null with no shell, or in a
-// shell without a header (the channel page), so a page renders its header nowhere there. Its own
+// controls into, whether a page has claimed it, and how a page claims it. The value is null with no
+// shell and the target null in a shell without a header (the channel page): a page renders its header
+// nowhere there. Its own
// module so PageShell exports only components (Fast Refresh).
export type PageHeaderSlotValue = {
target: HTMLElement | null;
claimed: boolean;
/** Claim the header for this page's controls; the returned function releases it. */
claim: () => () => void;
+ /** The default (PageHeaderDefault) reports whether it is in the header, so PageShell re-measures. */
+ showDefault: (shown: boolean) => void;
};
export const PageHeaderSlot = createContext(null);