Skip to content

fix(pages): inline critical CSS to remove the initial white flash (#601) - #610

Open
chethanuk wants to merge 2 commits into
alibaba:mainfrom
chethanuk:fix/601-pages-fouc-inline-critical-css
Open

fix(pages): inline critical CSS to remove the initial white flash (#601)#610
chethanuk wants to merge 2 commits into
alibaba:mainfrom
chethanuk:fix/601-pages-fouc-inline-critical-css

Conversation

@chethanuk

@chethanuk chethanuk commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Description

Removes the white flash on first load of the landing site by inlining a small critical-CSS block in pages/index.html and taking the two CDN stylesheets out of the critical path.

The cause is that pages/src/styles/index.css — including body { background-color: #000000 } — is imported from src/index.tsx and injected at runtime by style-loader. There is no MiniCssExtractPlugin, so no stylesheet exists at all until JS runs, and the bundle is split into four deferred chunks:

HTML parsed ─────────────────────────────► white canvas
  runtime.bundle.js → react.bundle.js → 696.bundle.js → main.bundle.js
                                                          └─► style-loader injects
                                                              body { background:#000 }

index.html had 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: #000000 and color: #ffffff on html and bodyhtml too, because that is the element the browser has resolved at first paint. Colours come from the body rule in index.css rather 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 the onload handler 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 in WhySection, well below the initial viewport. <noscript> copies keep both stylesheets applied when scripting is off, since the onload swap cannot run there.

Boot indicator via #root:empty::after. Visible only while #root has 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 under prefers-reduced-motion, where the ring still renders, just static.

One template feeds both HtmlWebpackPlugin instances, so dist/404.html — the SPA deep-link fallback GitHub Pages serves for unknown paths — gets the same treatment for free.

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Refactoring (no functional changes)
  • Documentation update
  • CI / Build / Tooling

How Has This Been Tested?

  • make test passes locally
  • Manual testing (below)

12 table-driven vitest assertions over index.html, including the three that matter most and are easiest to get wrong:

  • the inline colours still equal the body rule parsed out of index.css — the duplication is unavoidable, so this is what stops the two drifting into a visible shift;
  • both CDN links carry media="print" + the onload swap, since critical CSS is pointless while the browser is still blocked on them;
  • the <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:

OK dist/index.html  style@861 < stylesheet@1862 < script@2525  | 2 async links + noscript fallback
OK dist/404.html    style@861 < stylesheet@1862 < script@2525  | 2 async links + noscript fallback

npm run lint (0 errors; the 2 warnings are pre-existing in MarkdownRenderer.tsx) · npm run typecheck clean · npm test 12/12 · npm run build clean · npm run size 86.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 existing ErrorBoundary.test.tsx. I left the workflow alone rather than widen this PR.

Checklist

  • My code follows the project's coding style (go fmt, go vet)
  • I have performed a self-review of my code
  • I have added tests that prove my fix is effective or my feature works
  • New and existing unit tests pass locally with my changes
  • I have updated the documentation accordingly (if applicable)
  • I have signed the CLA

Out of scope

  • Self-hosting Inter / JetBrains Mono instead of fetching them from 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.
  • Extracting CSS with MiniCssExtractPlugin so 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

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

github-actions Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

🔍 OpenCodeReview found 1 issue(s) in this PR.

  • ✅ Successfully posted inline: 1 comment(s)

Comment thread pages/index.html
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; }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 lizhengfeng101 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-scheme meta 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.
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.

[pages] White flash (FOUC) on initial page load due to missing inline critical CSS

2 participants