fix(pages): inline critical CSS to remove the initial white flash (#601) - #610
fix(pages): inline critical CSS to remove the initial white flash (#601)#610chethanuk wants to merge 2 commits into
Conversation
…ibaba#601) Every rule in pages/src/styles/index.css — including `body { background-color: #000000 }` — is imported from JS and injected at runtime by style-loader, and the bundle is split into four deferred chunks. So no stylesheet exists at all until the last chunk executes, and index.html had no inline CSS: the browser painted its default white canvas for several hundred milliseconds before the page turned black. Inline a minimal critical block in the HtmlWebpackPlugin template, ahead of the two render-blocking CDN stylesheet links, with the same #000000/#ffffff as index.css so nothing shifts when the runtime CSS arrives. It applies to `html` as well as `body`, since `html` is what the browser has resolved at first paint. Also add `<meta name="color-scheme" content="dark">`. The Google Fonts and Font Awesome links are render-blocking, so first paint waits on them and the inline style cannot help until they load; the meta is what gives the browser a dark canvas in the meantime. The boot indicator hangs off `#root:empty::after`, so React's first render removes it with no JS teardown, and its animation is disabled under prefers-reduced-motion. One template feeds both HtmlWebpackPlugin instances, so dist/404.html (the SPA deep-link fallback) gets the same treatment. Tested: 7 table-driven vitest assertions over index.html, each verified to fail when the block is removed. lint / typecheck / build / size-limit clean (86.6 kB brotlied against the 150 kB gate), and both dist/index.html and dist/404.html confirmed to carry the block ahead of the first stylesheet and script tag.
|
🔍 OpenCodeReview found 1 issue(s) in this PR.
|
| Keep this block ahead of the stylesheet links below — that ordering is what | ||
| makes it critical CSS. --> | ||
| <style> | ||
| html, body { margin: 0; background-color: #000000; color: #ffffff; } |
There was a problem hiding this comment.
[maintainability · low]
The background color (#000000) and text color (#ffffff) are duplicated between this inline style and src/styles/index.css. While the comment acknowledges this coupling, there is no enforcement mechanism to keep them in sync. If someone updates the body colors in index.css without updating this block, users will see a visible color shift after the runtime CSS loads.
Consider defining these values as CSS custom properties in this inline block and referencing them in index.css, or adding a lint/build check that verifies consistency. For example:
:root { --ocr-bg: #000000; --ocr-fg: #ffffff; }
html, body { margin: 0; background-color: var(--ocr-bg); color: var(--ocr-fg); }Then use var(--ocr-bg) / var(--ocr-fg) in index.css as well.
There was a problem hiding this comment.
Fixed in c54a72a, though not with custom properties.
Referencing var(--ocr-bg) from index.css would make the runtime stylesheet depend on the inline HTML block — deleting or editing the block would then silently change index.css's output, which is the more fragile direction for this coupling to run.
The duplication is unavoidable (index.css does not exist at first paint), so the missing piece was the enforcement, not the DRY. The test now parses the body rule out of src/styles/index.css and compares it against the inline block, so changing one without the other fails:
× keeps the inline colours in sync with index.css
Mutation-checked by changing background-color in index.css alone — that assertion fails and no other does.
lizhengfeng101
left a comment
There was a problem hiding this comment.
The <meta name="color-scheme" content="dark"> introduces global side effects beyond the first paint.
It changes UA-styled form controls, scrollbars, and <dialog> backdrops to their dark variants site-wide — not just during the loading window. This is a broader behavioral change than the PR intends.
Suggested fix: make the CDN stylesheets non-render-blocking instead. This eliminates the white-flash window that the meta was designed to cover, without any global side effects:
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap"
rel="stylesheet" media="print" onload="this.media='all'" />
<noscript><link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet" /></noscript>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css"
rel="stylesheet" media="print" onload="this.media='all'" />
<noscript><link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css" rel="stylesheet" /></noscript>With this approach:
- The inline
<style>paints the dark background + spinner immediately (no render-blocking resources ahead of it) - Fonts and icons load asynchronously and apply when ready (FOUT instead of FOIT — text is visible sooner)
- No
color-schememeta needed, no UA control side effects - The
<noscript>fallback preserves fonts when JS is disabled
This also happens to improve LCP/FCP metrics since the browser no longer blocks first paint on two CDN round-trips.
Review feedback: `<meta name="color-scheme" content="dark">` reaches further
than this fix needs. It switches UA-rendered form controls, scrollbars and
<dialog> backdrops to their dark variants for the whole site, permanently —
not just during the loading window it was added to cover.
Take the root cause instead. The meta was only needed because the Google
Fonts and Font Awesome stylesheets are render-blocking, so first paint waited
on two CDN round-trips and the inline critical block could not apply until
they resolved. Loading them with media="print" + an onload swap drops them out
of the critical path entirely, so the inline block paints immediately and the
meta has nothing left to do.
Nothing above the fold regresses: the font URL already carries display=swap,
so text renders in a fallback and swaps, and the only Font Awesome icons are
the three in WhySection, well below the initial viewport. <noscript> copies
keep both stylesheets applied when scripting is off, since the onload swap
cannot run there.
Also close the gap flagged on the inline colours: they are duplicated from
index.css out of necessity, and nothing enforced that they stay equal. The
test now parses the `body` rule out of index.css and compares, so editing one
without the other fails rather than shipping a visible shift.
Structural assertions now run against the markup with HTML comments stripped.
The comment explaining this block names the tags it discusses, and an
indexOf('<noscript>') was matching the prose rather than the element.
Tested: 12 vitest assertions, each of the three new ones mutation-checked in
isolation (make the links blocking / desync index.css / drop the noscript —
each fails exactly its own assertion). lint / typecheck / build / size clean,
86.6 kB brotlied against the 150 kB gate. Both dist/index.html and
dist/404.html verified to carry the inline block ahead of any stylesheet or
script, two async links, and the noscript fallback.
Description
Removes the white flash on first load of the landing site by inlining a small critical-CSS block in
pages/index.htmland taking the two CDN stylesheets out of the critical path.The cause is that
pages/src/styles/index.css— includingbody { background-color: #000000 }— is imported fromsrc/index.tsxand injected at runtime bystyle-loader. There is noMiniCssExtractPlugin, so no stylesheet exists at all until JS runs, and the bundle is split into four deferred chunks:index.htmlhad no inline<style>and<body>held only an empty<div id="root">, so there was nothing to paint but the browser default.What changed
Critical block ahead of the links. Sets
background-color: #000000andcolor: #ffffffonhtmlandbody—htmltoo, because that is the element the browser has resolved at first paint. Colours come from thebodyrule inindex.cssrather than being picked, so nothing shifts when the runtime CSS lands.Both CDN stylesheets load without blocking first paint. Google Fonts and Font Awesome were render-blocking, so first paint waited on two CDN round-trips and the inline block could not apply until they resolved — inlining alone would not have removed the flash.
media="print"does not match the screen, so the browser fetches them without holding up rendering and theonloadhandler switches them on when they arrive.Nothing above the fold regresses: the font URL already carries
display=swap, so text renders in a fallback and swaps, and the only Font Awesome icons are the three inWhySection, well below the initial viewport.<noscript>copies keep both stylesheets applied when scripting is off, since theonloadswap cannot run there.Boot indicator via
#root:empty::after. Visible only while#roothas no children, so React's first render removes it — no JS to add it, none to tear it down, and no state that can leak if mounting fails. Its animation is disabled underprefers-reduced-motion, where the ring still renders, just static.One template feeds both
HtmlWebpackPlugininstances, sodist/404.html— the SPA deep-link fallback GitHub Pages serves for unknown paths — gets the same treatment for free.Type of Change
How Has This Been Tested?
make testpasses locally12 table-driven vitest assertions over
index.html, including the three that matter most and are easiest to get wrong:bodyrule parsed out ofindex.css— the duplication is unavoidable, so this is what stops the two drifting into a visible shift;media="print"+ theonloadswap, since critical CSS is pointless while the browser is still blocked on them;<noscript>fallback still names both stylesheets.Each was mutation-checked in isolation — make the links blocking, desync
index.css, drop the<noscript>— and each fails exactly its own assertion and nothing else. The original seven were verified the same way by reverting the whole block.Structural assertions run against markup with HTML comments stripped: the comments here name the tags they discuss, and an
indexOf('<noscript>')was matching the prose instead of the element.Built output checked, not just source:
npm run lint(0 errors; the 2 warnings are pre-existing inMarkdownRenderer.tsx) ·npm run typecheckclean ·npm test12/12 ·npm run buildclean ·npm run size86.6 kB brotlied against the 150 kB gate, unchanged since the block is HTML, not bundle.One note for reviewers: Pages CI runs install / lint / typecheck / build / size but not
npm test, so this test does not gate in CI — the same is true of the existingErrorBoundary.test.tsx. I left the workflow alone rather than widen this PR.Checklist
go fmt,go vet)Out of scope
fonts.googleapis.com. Now that the stylesheet is off the critical path the remaining win is smaller, but it would still remove a third-party dependency from page load.MiniCssExtractPluginso styles ship as a real stylesheet instead of inside the JS chunks. That is the actual root fix and would make the inline block redundant, but it changes the build for every page.Happy to file either of these.
Related Issues
Closes #601