Skip to content

fix: stop the contents sidebar flashing open on page load - #123

Open
DrDrij wants to merge 1 commit into
mainfrom
fix/sidebar-flash-on-load
Open

fix: stop the contents sidebar flashing open on page load#123
DrDrij wants to merge 1 commit into
mainfrom
fix/sidebar-flash-on-load

Conversation

@DrDrij

@DrDrij DrDrij commented Aug 18, 2026

Copy link
Copy Markdown
Member

The contents sidebar flashes open for a moment on page load, then slides shut. Reported against the Netlify preview of QuantEcon/lecture-python-programming#363.

Cause

Not a state bug — the server-rendered markup is correct and already carries -translate-x-full.

On a static build every navigation is a full document load, and the first paint can happen before app.css applies. This is the same FOUC as quantecon-theme-src#66, which the inlined critical CSS already addresses for the font and the content grid — but that critical CSS never covered the sidebar.

So the sequence is:

  1. First paint happens with no app.css. -translate-x-full, fixed and w-[250px] all mean nothing, so the panel lays out as a plain in-flow block, full width and fully visible.
  2. app.css arrives and transform resolves from none to translateX(-100%).
  3. Because transition-all duration-300 had been on the element since that first paint, the browser animates the correction.

The menu is never actually opened. It was simply never hidden, and then took 300ms to put itself away — which is why a one-frame glitch reads as a deliberate animation.

Measured

Two builds of this repo, served with myst start on the visual fixture. app.css is served empty so the first paint happens unstyled — the field condition — then the real stylesheet is applied and the panel traced every frame.

BEFORE (main)
  first paint : x = 0px, width = 1280px   ON SCREEN
  on css apply: 15 properties animate (background-color, border-*, padding-*, tab-size, transform)
                x: 0 -> -2 -> -8 -> -21 -> -44 -> -77 -> -110 -> -148 -> -174 -> -194 ...
                still visible for 214ms

AFTER (this branch)
  first paint : x = -250px, width = 250px   off screen
  on css apply: x holds at -250 across all samples — snapped, no motion
                still visible for 0ms

Note that simply delaying app.css does not reproduce this: Chrome then blocks rendering and never paints, which is the healthy path. The flash needs a paint that beats the stylesheet.

Fix

Two guards, because either alone still leaves an artefact:

  • app/root.tsx — a .qe-contents-sidebar rule in the existing critical CSS parks the panel off-screen on the first paint. Its width/position match the Tailwind classes so the resolved transform is identical before and after app.css lands, meaning nothing can animate on arrival.
  • app/components/ContentsSidebar.tsx — the transition classes are withheld until after mount, so any residual correction is applied instantly rather than animated.

transition-all is also narrowed to transition-transform. The former animated 15 properties when only the slide was ever wanted; the latter is compositor-only.

Opening and closing the menu is unaffected — it still animates normally once mounted (-250 -> 0 -> -250, animating transform alone).

A trap worth recording

The critical CSS uses :where(), so it carries zero specificity. That means every property set there must also be declared by the real stylesheet, or it can never be overridden. Hiding the panel with visibility: hidden would have pinned it shut permanently, since no Tailwind class sets visibility. Hence the transform. This constraint is now documented in the block comment.

Tests

The existing WebKit FOUC guard covers this, since the cause and the guard are the same. It now also asserts the panel starts off-screen — and its control case asserts the flash does reproduce when the inline rule is removed, so the guard cannot silently rot.

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.
@github-actions

Copy link
Copy Markdown
Contributor
PR Preview Action v1.8.1

QR code for preview link

🚀 View preview at
https://QuantEcon.github.io/quantecon-theme.mystmd/pr-preview/pr-123/

Built to branch gh-pages at 2026-08-18 20:43 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

@github-actions

Copy link
Copy Markdown
Contributor

🎭 Visual regression results

passed  15 passed
skipped  1 skipped

Details

stats  16 tests across 1 suite
duration  32.7 seconds
commit  04b4e3b

Skipped tests

mobile-chrome › theme.spec.ts › QuantEcon theme — visual regression › launch-colab

@mmcky

mmcky commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Nice piece of debugging — the diagnosis is right and the primary guard demonstrably works. I built each variant and traced the panel frame-by-frame in WebKit (serve app.css empty so the first paint is unstyled, then apply the real sheet):

build first paint after app.css applies
main x=0, width=1280 — fully visible 14 frames on screen, ~275 ms, sliding shut
this PR x=−250, right=0 0 frames on screen
critical CSS only, no mounted gate x=−250, right=0 0 frames on screen

Also green: tsc --noEmit, webkit-fouc, and desktop-chrome 8/8 + mobile-chrome 7/7 against the committed -darwin baselines — no snapshot refresh needed. I re-checked the :where() invariant for all five new properties; transform was the one that could have broken it, and both states are covered (.-translate-x-full sits in a grouped transform rule at class specificity, so it beats :where()).

One thing I'd change before merge — the new sync-list comment in app/root.tsx:

width/height/position must match its Tailwind classes so the resolved transform is identical before and after app.css lands

Neither half quite holds. Resolved widths are 350 / 250 / 350 across the three bands (base w-[350px] wins — it lands after w-[250px] in the compiled base layer), so the rule's width:250px is off at two of three; and the used transform goes −250px → −350px, not "identical". It really is harmless, but for a reason the comment misses and which is the more useful thing to record: translateX(-100%) resolves against the element's own border box, so the right edge sits at left + W − W = 0 for any W. Either drop width from the rule, or keep it and say that instead. (The element also carries duplicate unprefixed w-[350px] and w-[250px] — pre-existing, but deduping would let the comment be true.)

A few smaller notes, take or leave:

  • The mounted gate doesn't look load-bearing: guard 1 alone gave the same 0 on-screen frames (row 3), for the same percentage-transform reason. Very happy to keep it as insurance — I'd just soften "either alone still leaves an artefact". It also has no coverage; I deleted mounted &&, rebuilt, and the suite stayed green.
  • sidebar ? ...right > 0 : false returns the passing value when the element is absent. I renamed the class in the component only: the main test passed and the control failed with Expected: true, Received: false. So the suite does catch it, just in the wrong test with a message that never mentions the missing hook. Returning null and asserting presence would point straight at it.
  • ## [Unreleased] in CHANGELOG.md is still empty — CONTRIBUTING asks for an entry per change.

Two things I hit while reviewing are pre-existing and out of scope here, so I filed them separately: #126 (React hydration failing on every load, which also makes the FOUC control test racy) and #127 (the sidebar toggle icons animate the same first-paint correction).

@mmcky

mmcky commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

@DrDrij here is a review pass. Can you let me know if this is helpful or not? Thanks.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants