瀏覽代碼

Neo: keep header/footer height at the SSR value while hydrating (#5798)

PageHeader measures itself and writes the header height atom before later
Suspense boundaries hydrate, so Page rendered min-height from the measured
height against server HTML built from the 64px seed. React doesn't patch
that attribute mismatch. It shows in any project whose header isn't 64px.

useHeaderHeight and useFooterHeight now return the seed during hydrating
renders (useHydrated from measure.ts, per boundary) and the measured value
after. The header seed comes from the h-N class in layout.header.content,
so fixed-height headers in forks don't shift after hydration either.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Andrey Yasko 1 周之前
父節點
當前提交
c5b0485f53

+ 31 - 5
neo/packages/app/context/jotai/layout.ts

@@ -4,6 +4,8 @@ import { atom, useSetAtom, useAtomValue, type Atom, type PrimitiveAtom, type Wri
 type NumberAtom = PrimitiveAtom<number>;
 type NumberAtom = PrimitiveAtom<number>;
 import { Platform } from 'react-native'
 import { Platform } from 'react-native'
 import { useTabChromeKey } from 'app/context/tab-chrome';
 import { useTabChromeKey } from 'app/context/tab-chrome';
+import { useHydrated } from 'app/context/measure';
+import { appSetting } from 'app/lib/util';
 const isWeb = Platform.OS == 'web'
 const isWeb = Platform.OS == 'web'
 
 
 export const footerAtom = atom<boolean>(true);
 export const footerAtom = atom<boolean>(true);
@@ -31,11 +33,23 @@ export const coverScrollCompensationAtom = atom<number>(0);
 /** Scroll offset at which the native profile cover finishes collapsing (see conductor.js). */
 /** Scroll offset at which the native profile cover finishes collapsing (see conductor.js). */
 export const COVER_COLLAPSE_SCROLL = 500;
 export const COVER_COLLAPSE_SCROLL = 500;
 /**
 /**
- * SSR/hydration seed for headerHeightAtom on web only.
+ * Height in px of the first unprefixed `h-N` or `h-[Npx]` class, e.g. `h-14` → 56.
+ * Responsive variants (`sm:h-16`) are ignored: SSR can't know the viewport.
+ */
+function heightFromClasses(classes: unknown, fallback: number): number {
+    if (typeof classes !== 'string') return fallback;
+    for (const cls of classes.split(/\s+/)) {
+        const match = /^h-(?:(\d+(?:\.\d+)?)|\[(\d+(?:\.\d+)?)px\])$/.exec(cls);
+        if (match) return match[1] ? Number(match[1]) * 4 : Number(match[2]);
+    }
+    return fallback;
+}
+/**
+ * SSR/hydration seed for headerHeightAtom on web only, taken from the
+ * `layout.header.content` height class (64 when it has none).
  * After mount, PageHeader `onLayout` replaces this with the measured height.
  * After mount, PageHeader `onLayout` replaces this with the measured height.
- * Keep close to `layout.header.content` height (e.g. h-14 → 56) to minimize layout shift.
  */
  */
-export const DEFAULT_HEADER_HEIGHT = 64;
+export const DEFAULT_HEADER_HEIGHT = heightFromClasses(appSetting('layout', 'header', 'content'), 64);
 export const headerHeightAtom = atom<number>(isWeb ? DEFAULT_HEADER_HEIGHT : 0);
 export const headerHeightAtom = atom<number>(isWeb ? DEFAULT_HEADER_HEIGHT : 0);
 export const footerHeightAtom = atom<number>(0);
 export const footerHeightAtom = atom<number>(0);
 
 
@@ -113,11 +127,23 @@ export const useSetCoverScrollCompensation = () => useSetAtom(getCoverScrollComp
 export const useCoverScrollCompensation = () => useAtomValue(getCoverScrollCompensationAtomForTab(useTabChromeKey()));
 export const useCoverScrollCompensation = () => useAtomValue(getCoverScrollCompensationAtomForTab(useTabChromeKey()));
 
 
 export const useSetHeaderHeight = () => useSetAtom(getHeaderHeightAtomForTab(useTabChromeKey()));
 export const useSetHeaderHeight = () => useSetAtom(getHeaderHeightAtomForTab(useTabChromeKey()));
-export const useHeaderHeight = () => useAtomValue(getHeaderHeightAtomForTab(useTabChromeKey()));
+/**
+ * Hydrating renders return the SSR value: the header measures itself before
+ * later Suspense boundaries hydrate, and a measured height there would not
+ * match the server HTML (e.g. Page's min-height). See useHydrated.
+ */
+export const useHeaderHeight = () => {
+    const height = useAtomValue(getHeaderHeightAtomForTab(useTabChromeKey()));
+    return useHydrated() ? height : DEFAULT_HEADER_HEIGHT;
+};
 
 
 export const useSetPageBottomBlur = () => useSetAtom(getPageBottomBlurAtomForTab(useTabChromeKey()));
 export const useSetPageBottomBlur = () => useSetAtom(getPageBottomBlurAtomForTab(useTabChromeKey()));
 /** Read outside the tab's chrome provider (the tab screen itself), hence the explicit key. */
 /** Read outside the tab's chrome provider (the tab screen itself), hence the explicit key. */
 export const usePageBottomBlur = (tabKey: string) => useAtomValue(getPageBottomBlurAtomForTab(tabKey));
 export const usePageBottomBlur = (tabKey: string) => useAtomValue(getPageBottomBlurAtomForTab(tabKey));
 
 
 export const useSetFooterHeight = () => useSetAtom(footerHeightAtom);
 export const useSetFooterHeight = () => useSetAtom(footerHeightAtom);
-export const useFooterHeight = () => useAtomValue(footerHeightAtom);
+/** Hydrating renders return the SSR value (0), like useHeaderHeight. */
+export const useFooterHeight = () => {
+    const height = useAtomValue(footerHeightAtom);
+    return useHydrated() ? height : 0;
+};

+ 1 - 1
neo/packages/app/context/measure.ts

@@ -89,7 +89,7 @@ export const useActualWindowHeight = () => {
 const subscribeNoop = () => () => {};
 const subscribeNoop = () => () => {};
 const getHydratedOnClient = () => true;
 const getHydratedOnClient = () => true;
 const getHydratedOnServer = () => false;
 const getHydratedOnServer = () => false;
-const useHydrated = () => useSyncExternalStore(subscribeNoop, getHydratedOnClient, getHydratedOnServer);
+export const useHydrated = () => useSyncExternalStore(subscribeNoop, getHydratedOnClient, getHydratedOnServer);
 
 
 // SSR and hydrating renders use SSR_LAYOUT_BREAKPOINT (desktop, like useIsDesktop), so layouts
 // SSR and hydrating renders use SSR_LAYOUT_BREAKPOINT (desktop, like useIsDesktop), so layouts
 // that switch structure on the breakpoint (universal.js rows vs panels, wiki/post flags) hydrate
 // that switch structure on the breakpoint (universal.js rows vs panels, wiki/post flags) hydrate

+ 1 - 1
neo/packages/app/ui/molecules/sections/conductor/web-ui.js

@@ -1097,7 +1097,7 @@ export const HeaderContainer = ({
     const pageHeaderHeight = useHeaderHeight()
     const pageHeaderHeight = useHeaderHeight()
     const uri = pageData?.uri
     const uri = pageData?.uri
     // Mobile profile hides the page header (`header: false`); pin morph chrome
     // Mobile profile hides the page header (`header: false`); pin morph chrome
-    // at the viewport top, not at the leftover DEFAULT_HEADER_HEIGHT (64).
+    // at the viewport top, not at the leftover DEFAULT_HEADER_HEIGHT seed.
     const stickyTop =
     const stickyTop =
         !isDesktop && layoutName === 'profile' ? 0 : (pageHeaderHeight || 0)
         !isDesktop && layoutName === 'profile' ? 0 : (pageHeaderHeight || 0)
     // Remounts the morph when the cover image or avatar changes; a plain data
     // Remounts the morph when the cover image or avatar changes; a plain data