From 735411391aa7881ff04f422a70d0311c80fb17d4 Mon Sep 17 00:00:00 2001 From: DrDrij Date: Wed, 19 Aug 2026 00:24:53 +0700 Subject: [PATCH 1/3] fix: stop contents sidebar flashing open on page load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On the static build every navigation is a full document load, and the first paint can happen before app.css applies. Until it does, `-translate-x-full` means nothing, so the nav panel painted in-flow and fully visible. When the stylesheet finally arrived the transform resolved, and because `transition-all` was already on the element the correction was animated — the menu appeared to open, then slide shut over 300ms. Two guards, since either alone leaves a visible artefact: - Park the panel off-screen in the inlined critical CSS, so it is never visible on the first paint. Width/height/position match the Tailwind classes so the resolved transform is identical before and after app.css lands and nothing animates on arrival. - Withhold the transition classes until after mount, so any remaining correction is applied instantly rather than animated. Also narrows `transition-all` to `transition-transform`: the former animated 15 properties (background, borders, padding, tab-size) where only the slide was wanted. Covered by the existing FOUC guard, which already isolates the inline critical CSS and now asserts the panel starts off-screen. --- app/components/ContentsSidebar.tsx | 29 ++++++++++++++++++++++++++++- app/root.tsx | 10 ++++++++++ tests/visual/fouc.spec.ts | 15 ++++++++++++++- 3 files changed, 52 insertions(+), 2 deletions(-) diff --git a/app/components/ContentsSidebar.tsx b/app/components/ContentsSidebar.tsx index 8e3fb270b..aaec700ed 100644 --- a/app/components/ContentsSidebar.tsx +++ b/app/components/ContentsSidebar.tsx @@ -12,6 +12,7 @@ import { import { useSidebarHeight } from '@myst-theme/site'; import classNames from 'classnames'; import { slugToUrl } from 'myst-common'; +import { useEffect, useState } from 'react'; type StrictHeading = Omit & { level: number }; type HeadingGroup = StrictHeading[]; @@ -43,8 +44,22 @@ function Section({ group }: { group: HeadingGroup }) { ); } +/** + * True only after the component has mounted on the client. + * + * Both the server render and the first (hydrating) client render return + * `false`, so the markup matches and React does not warn; the effect then + * flips it on the frame after hydration. + */ +function useMounted() { + const [mounted, setMounted] = useState(false); + useEffect(() => setMounted(true), []); + return mounted; +} + export function ContentsSidebar() { const [open] = useNavOpen(); + const mounted = useMounted(); const config = useSiteManifest(); const project = useProjectManifest(); const top = useThemeTop(); @@ -84,13 +99,25 @@ export function ContentsSidebar() {
= ({ data }) => { * arrives. This keeps the inline block from overriding the live cascade despite * being emitted after in the document head. * + * Because these rules carry no specificity, every property set here MUST also + * be declared by the real stylesheet, otherwise it can never be overridden. + * (E.g. parking the nav panel off-screen with `visibility:hidden` would stick + * forever, since no Tailwind class sets `visibility` — hence the transform.) + * * Keep the values in sync with their sources of truth: * - font stack: tailwind.config.js -> theme.extend.fontFamily.sans * - grid columns: tailwind.config.js -> theme.extend.gridTemplateColumns @@ -65,6 +70,10 @@ export const meta: V2_MetaFunction = ({ data }) => { * page background; the inner content panel uses `qepage-dark` * #222, see app/components/Page.tsx — intentionally not set here * since these rules target .) + * - nav panel: app/components/ContentsSidebar.tsx -> `.qe-contents-sidebar` + * (width/height/position must match its Tailwind classes so + * the resolved transform is identical before and after + * app.css lands, and nothing animates on arrival) */ const CRITICAL_CSS = ` :where(html){font-family:"Source Sans 3",sans-serif} @@ -74,6 +83,7 @@ const CRITICAL_CSS = ` :where(.simple-center-grid){display:grid;grid-template-columns:[screen-start] 1fr [body-start] minmax(300px,800px) [body-end] 1fr [screen-end]} :where(.simple-center-grid) > *{grid-column:body-start / body-end} @media (min-width:1280px){:where(.simple-center-grid){grid-template-columns:[screen-start] 1fr 200px 20px [body-start] 800px [body-end] 20px [margin-start] 200px [margin-end] 1fr [screen-end]}} +:where(.qe-contents-sidebar){position:fixed;left:0;width:250px;height:100vh;transform:translateX(-100%)} `; export const links: LinksFunction = () => { diff --git a/tests/visual/fouc.spec.ts b/tests/visual/fouc.spec.ts index 684155523..8e21944ab 100644 --- a/tests/visual/fouc.spec.ts +++ b/tests/visual/fouc.spec.ts @@ -9,6 +9,11 @@ import { test, expect, type Page, type Route } from "@playwright/test"; * grid collapsed to `display: block`. The fix inlines critical CSS into `` * (see `app/root.tsx`), which parses synchronously and styles that first paint. * + * The same unstyled frame also exposed the contents sidebar: `-translate-x-full` + * does nothing until app.css lands, so the panel painted in-flow and visible, + * then slid shut once the stylesheet arrived — the "menu opens on load" report. + * It is covered here too, since the cause and the guard are the same. + * * This test makes the failure mode deterministic by **aborting all external * stylesheets**, so the only styling that can reach the page is the inline * `