Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 23 additions & 7 deletions app/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,29 @@ export const meta: V2_MetaFunction<typeof loader> = ({ data }) => {
* Critical CSS — inlined in <head> to fix the Safari/WebKit FOUC on navigation
* (https://github.com/QuantEcon/quantecon-theme-src/issues/66).
*
* Static builds (`myst build --html`) navigate via full document loads, and
* WebKit paints the freshly-navigated document for ~1 frame BEFORE any <link>
* stylesheet applies — even the same-origin Tailwind app.css. That frame shows
* the default serif font and the content grid collapsed to `display: block`
* (i.e. the "raw HTML" flash users report). An inline <style> is parsed
* synchronously with the document, so it styles that very first paint with no
* network round-trip.
* The mechanism is NOT a pre-stylesheet first paint. WebKit holds first paint
* until the render-blocking <head> stylesheets apply — probed with a
* rAF-from-document-start sampler: no frame renders unstyled on the static
* build, cold or warm cache, even with every CSS response delayed 800ms. The
* unstyled frame arrives ~200ms AFTER first paint, when React hydration fails
* (minified #418/#423) and the recovery client render re-patches whatever
* diverged between server and client markup. A divergence in the <head> makes
* that pass re-create head nodes (#126 measured it re-inserting a missing
* <style> at 195ms) — and a re-inserted stylesheet <link> re-applies
* asynchronously, while a re-inserted inline <style> applies the instant the
* node lands. In that gap this block is the only styling on the page, which
* is the styled -> unstyled -> styled flicker users reported as the "raw
* HTML" flash. A body-level mismatch, by contrast, recovers without touching
* the head at all (probed by injecting a stray node into the served <body>:
* both errors fire, zero head mutations, no flash).
*
* Current builds hydrate cleanly — no #418/#423 on any fixture page, dev or
* static — so day to day this block is the safety net for whenever the
* external stylesheets are absent, however that comes about; the FOUC guard
* test simulates that state by aborting them. The practical rule stands
* regardless of mechanism: anything that must not flash (hidden by opacity,
* gated by a transition) needs a rule HERE — at flash time React is already
* mounted, so mount-gating cannot help, and only this inline CSS survives.
*
* Every selector is wrapped in `:where(...)` so these rules carry **zero**
* specificity: they take effect only while nothing else has loaded, and the
Expand Down
19 changes: 12 additions & 7 deletions tests/visual/fouc.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,15 @@ import { test, expect, type Page, type Route } from "@playwright/test";
/**
* FOUC guard (WebKit) — QuantEcon/quantecon-theme-src#66.
*
* In the static build every navigation is a full document load, and WebKit
* paints the fresh document *before* its external `<link>` stylesheets apply —
* so for ~1 frame the page renders with the default serif font and the content
* grid collapsed to `display: block`. The fix inlines critical CSS into `<head>`
* (see `app/root.tsx`), which parses synchronously and styles that first paint.
* The flash this guards against is NOT a pre-stylesheet first paint — WebKit
* holds first paint until the `<head>` stylesheets apply. It is the window
* where those stylesheets stop applying after load: historically, React's
* hydration-recovery re-render re-creating head nodes when server and client
* markup diverged there, which re-applies a `<link>` asynchronously but an
* inline `<style>` synchronously (see the CRITICAL_CSS comment in
* `app/root.tsx`, and #126 for the measurements). In that window the page
* shows the default serif font and the content grid collapsed to
* `display: block` unless the inlined critical CSS covers it.
*
* The contents drawer is checked here too: any panel that relies on author CSS
* to stay hidden paints open in that same frame. It is a popover now, so the UA
Expand All @@ -21,8 +25,9 @@ import { test, expect, type Page, type Route } from "@playwright/test";
* assertion below fails. The control case strips the inline block to prove the
* abort genuinely removes external styling (otherwise the guard would be moot).
*
* Runs in the `webkit-fouc` Playwright project only — Chromium paint-holds and
* cannot exhibit this flash.
* Runs in the `webkit-fouc` Playwright project only — the flash was only ever
* observed in Safari/WebKit (quantecon-theme-src#66), and this suite's
* abort-the-stylesheets simulation exercises the guard there.
*/

const PAGE = "/";
Expand Down
Loading