Skip to content

docs: correct the FOUC mechanism in the critical-CSS and guard comments - #175

Merged
mmcky merged 1 commit into
mainfrom
docs/fouc-mechanism
Sep 10, 2026
Merged

docs: correct the FOUC mechanism in the critical-CSS and guard comments#175
mmcky merged 1 commit into
mainfrom
docs/fouc-mechanism

Conversation

@DrDrij

@DrDrij DrDrij commented Sep 8, 2026

Copy link
Copy Markdown
Member

Summary

Comment-only change. The CRITICAL_CSS block in app/root.tsx and the fouc.spec.ts header both explained the FOUC as WebKit painting the freshly-navigated document for ~1 frame before the head stylesheets apply. The v2.5.0 review established otherwise, and this PR rewrites both comments to the verified mechanism so the next person chasing a flash reasons from the right model.

What actually happens, per fresh measurements against v2.5.0 (rAF-from-document-start sampler plus a head MutationObserver, WebKit, both myst start and the static build):

  • WebKit holds first paint until the render-blocking head stylesheets apply. No frame ever renders unstyled on a healthy load — cold or warm cache, every fixture page, even with all CSS responses delayed 800ms.
  • The unstyled flash comes ~200ms after first paint, when React hydration fails (#418/#423) and the recovery client render re-patches whatever diverged. A head-level divergence re-creates head nodes (React hydration fails on every page load (#418/#423), which makes the FOUC control test racy #126 measured a missing <style> re-inserted at 195ms); a re-inserted <link> re-applies asynchronously while a re-inserted inline <style> applies the instant it lands, so the critical CSS is the only styling in that gap.
  • A body-level mismatch recovers without touching the head: injecting a stray node into the served <body> fires both React errors, with zero head mutations and no flash. (This also proves the error probe can fail.)
  • Current builds hydrate cleanly — no #418/#423 on any fixture page, dev or static. The failure React hydration fails on every page load (#418/#423), which makes the FOUC control test racy #126 reported no longer reproduces, so day to day the critical CSS is the safety net for whenever the stylesheets are absent, however that comes about.

The rewritten comments also record the practical rule this implies: anything that must not flash (hidden by opacity, gated by a transition) needs a critical-CSS rule, because at flash time React is already mounted — mount-gating cannot help, and only the inline CSS survives. That is the pattern #168 applied to the back-to-top button.

The webkit-fouc-only justification in the test header ("Chromium paint-holds and cannot exhibit this flash") leaned on the old model, so it now points at where the flash was actually observed (quantecon-theme-src#66) instead.

Testing

  • No behaviour change — comments only. npm run compile clean.
  • webkit-fouc Playwright project: 2 passed (guard and control both still meaningful under the corrected model; the abort-the-stylesheets simulation is mechanism-agnostic).
  • No snapshots touched, so no /update-snapshots needed.

The CRITICAL_CSS comment in app/root.tsx and the fouc.spec.ts header
both said WebKit paints the freshly-navigated document for ~1 frame
before the head stylesheets apply. The v2.5.0 review found otherwise,
and probing confirms it: WebKit holds first paint until the
render-blocking stylesheets apply. A rAF-from-document-start sampler
against the static build never caught an unstyled frame — cold or warm
cache, across every fixture page, even with all CSS responses delayed
by 800ms.

The flash the critical CSS guards against comes ~200ms AFTER first
paint instead, when React hydration fails (#418/#423) and the recovery
client render re-patches whatever diverged. A head-level divergence
re-creates head nodes (#126 measured a missing <style> re-inserted at
195ms); a re-inserted <link> re-applies asynchronously while an inline
<style> applies the instant it lands, so the critical CSS is the only
styling in that gap. A body-level mismatch 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).

Also measured, worth recording: current builds hydrate cleanly — no
#418/#423 on any fixture page, dev server or static build — so the
failure #126 reported no longer reproduces, and the probe that says so
detects the errors when a mismatch is forced.

Comments only; no behaviour change. tsc clean, webkit-fouc suite
2 passed.
@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-09-10 05:12 UTC

@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

🎭 Visual regression results

passed  19 passed
skipped  5 skipped

Details

stats  24 tests across 1 suite
duration  39.1 seconds
commit  b60255f

Skipped tests

mobile-chrome › theme.spec.ts › QuantEcon theme — visual regression › without JavaScript › drawer-opens-without-javascript
mobile-chrome › theme.spec.ts › QuantEcon theme — visual regression › drawer-closes-when-search-opens
mobile-chrome › theme.spec.ts › QuantEcon theme — visual regression › launch-colab
mobile-chrome › theme.spec.ts › QuantEcon theme — visual regression › live-compute-toggle
mobile-chrome › theme.spec.ts › QuantEcon theme — visual regression › live-compute-toggle-absent-without-thebe

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟢 Approval recommended

It is a comments-only documentation change with no functional impact, and the rewritten text is accurate, internally consistent, and consistent with the surrounding code and referenced issues.

Pull request overview

This PR corrects the explanatory comments that describe the FOUC (Flash Of Unstyled Content) mechanism in the critical-CSS block. Previously, both the CRITICAL_CSS comment in app/root.tsx and the fouc.spec.ts header attributed the flash to WebKit painting a freshly-navigated document for ~1 frame before the head stylesheets apply. Fresh measurements against v2.5.0 established that WebKit actually holds first paint until the render-blocking head stylesheets apply, and the unstyled flash instead arrives ~200ms later during React's hydration-recovery re-render (#418/#423), when a re-created head node can briefly drop the stylesheet. The comments are rewritten to document this verified model and the practical rule it implies (anything that must not flash needs an inline critical-CSS rule, since React is already mounted at flash time).

Changes:

  • Rewrites the CRITICAL_CSS mechanism comment in app/root.tsx to describe the post-hydration re-render as the true flash window rather than a pre-stylesheet first paint.
  • Rewrites the fouc.spec.ts header to match the corrected mechanism and re-justify the webkit-fouc-only scoping by where the flash was actually observed.
  • No behavioral/code changes — comments only.
File summaries
File Description
app/root.tsx Corrects the CRITICAL_CSS block comment to explain the hydration-recovery flash mechanism and the inline-CSS-only mitigation rule.
tests/visual/fouc.spec.ts Updates the test's header comment to reflect the corrected mechanism and re-scope the WebKit-only justification.

The rewritten comments are internally consistent, consistent with the pre-existing back-to-top explanation already present in app/root.tsx (lines 127–136), and consistent with the referenced issues (#126, React #418/#423, quantecon-theme-src#66). No spelling, grammar, factual, or cross-reference issues were found, and the JSDoc blocks remain well-formed.

Review details
  • Files reviewed: 2/2 changed files
  • Comments generated: 0
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@mmcky
mmcky merged commit 1aae2c8 into main Sep 10, 2026
5 checks passed
@mmcky
mmcky deleted the docs/fouc-mechanism branch September 10, 2026 05:12
quantecon-services pushed a commit that referenced this pull request Sep 11, 2026
Reverts the CHANGELOG half of b8b8101, keeping the app/seo.ts og:type
comment it also carried.

I added the entry on a Copilot review flag citing CONTRIBUTING's
"add your change to `## [Unreleased]`" rule, without checking whether this
PR had already taken a position. It had: the description says no entry,
because like #175 -- which corrected the FOUC comments and touched no
CHANGELOG -- this changes nothing a consumer of the theme can see. Verified
that 1aae2c8 (#175) indeed has no CHANGELOG entry, so the cited precedent
holds. The author's call stands.

It was also the sole cause of the merge conflict against main: #199 released
v2.7.0, moving the `## [Unreleased]` entries this added to into `## [2.7.0]`.
With the entry gone the PR no longer touches CHANGELOG.md at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QAy8brFG9x8fXet3AV9dJE
mmcky added a commit that referenced this pull request Sep 11, 2026
* chore(comments): code comments explain the code, not its history

Comments across app/, styles/, tests/, the workflows and config named
project phases, tracker issues and PLAN.md items, and justified values
by pointing at the Sphinx build. Rewrite them to state the reason
itself: the measurement, the contrast ratio, the constraint. A link
stays only where its thread holds detail a comment cannot give in a
line (a fork feature the code reads, a guarded regression, measured
timings), and ported code keeps one provenance line.

Add a "Code comments" section to CONTRIBUTING.md with the rule.

Comment-only: every changed code file is identical once comments are
stripped (TypeScript printer for TS/JS, postcss for CSS, parsed YAML
and JSON). The one data change drops an issue tag from the stderr
output text in the no-thebe fixture notebook.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(tests): name test suites by behaviour, not by issue number

Drop the issue numbers from four describe titles, the Sphinx framing
from an seo unit-test title, and the tracker link from the FOUC guard's
CI step name. Screenshot baselines are named explicitly in each
toHaveScreenshot call, so no snapshot moves. Also restate the
tailwind.config.js grid-track comment and a FOUC-suite note in the
present tense.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(comments): correct comment facts found in an accuracy review

An adversarial review of the rewritten comments (reviewers over every
changed file, a whole-codebase sweep for missed framing, and a
three-lens panel per finding) confirmed factual errors and leftover
history framing. Each was re-checked before applying:

- contrast: the stderr label is 5.5:1 and the link 5.08:1, six light
  code colours miss AA, and dark-mode ratios are restated for the #222
  content panel the text actually sits on
- sizes: the UNITS root under the toolbar font-size control, and figure
  and paragraph spacing quoted in px for what is em
- comments contradicting the code: the seo and route meta tags, the
  first-paint font, the dark-bg keep-in-sync pointer, the static-build
  claims in playwright.config.ts and preview.yml, the relative-css
  directory note, and the template.yml README pointer
- history framing the first pass missed: Makefile, .npmrc, serve.sh,
  mpl-widget.css, Document.tsx, the launch-urls tests,
  PageHeaderHistory.tsx and a release.yml step comment
- CONTRIBUTING: git log -L rather than git blame, and measurements as
  a reason to keep a link

Still comment-only: every changed file is identical to main once
comments are stripped from both sides.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(comments): say why Colab is the only launch target

Restore the design decision the rewrite dropped from the launch-colab
test comment: Binder and JupyterHub are deliberately not offered. And
reword the code-palette note so it no longer explains the missing
toggle by an option this theme never had.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(comments): keep the reason when the reason is another system

Follow-up to the code-comments rewrite, from an accuracy review of #200.

The rewrite read "matches the Sphinx build" as project framing and removed
it everywhere: Sphinx mentions in source fell from 95 across 15 files to 1.
For this theme that phrase is the specification rather than history -- the
theme reproduces the deployed lecture sites -- so where it was the only
justification for a value, the replacement restated the declaration instead
of giving a reason.

CONTRIBUTING.md: state the section's scope (code comments, including YAML,
shell and fixture comments; prose docs keep their history), fold the
overlapping first two bullets into one ban plus one exception, and say that
a constraint from another system is a reason, with a worked example and a
note to record deliberate divergences.

styles/quantecon.css: restore the derivation for the four values whose only
reason was removed -- the footer's 0.9rem and 0.7 opacity, the 5px rule's
18px gap, and the back-to-top 15.3px, whose comment had come to contradict
itself by deriving a deliberately unpinned size from `.article`. Restore the
two deleted notes: the known divergences on link underlines and `:visited`,
and that self-hosting Source Code Pro is separate work. Add a header note so
a reader knows what the measurements are measurements of. List all six
AA-failing code colours rather than counting them, and record that the
palette is inherited from quantecon-book-theme, so changing it is a decision
for both themes (see #172).

static.spec.ts: the provider `Link` would resolve against the un-slashed SSR
pathname; the code does not use it, so the present tense contradicted the
same explanation in Outline.tsx.

Fixture frontmatter comments: drop the issue labels the rule bans, matching
the myst.yml.in files beside them. The labels in features.md, lists.md and
the lists.md fancy-list line are rendered page text feeding features.png and
lists.png, so they are left for a run of update-snapshots.yml.

Reflow the comment lines that were edited in place and left short.

Comment-only, verified mechanically against 200c4d1 with a comment stripper
for TS/TSX/JS and CSS: no code token changes. tests/unit is unchanged at
17 passing; the 2 failures are missing node_modules in this environment and
are identical before and after.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QAy8brFG9x8fXet3AV9dJE

* docs(contributing): record the match-except-for-WCAG rule

The theme already diverges from the lecture builds in four places and every
one is an accessibility fix -- the outline's undimmed resting entries, its
non-colour-only active state, the dark footer link, and the flat rem-rooted
content size. That rule was only discoverable by reading four scattered
comments, so state it: match the lecture builds, except where matching them
fails WCAG, and say so with the measurement where you diverge. Changing an
inherited value for any other reason stays a two-theme decision (#172 for the
code palette, #201 for the footer).

Note that a contrast ratio must be computed on the composited colour, since
an `opacity` on the element is what the reader actually sees. That is the
step the footer missed: the dark footer link was corrected for it and the
light one never was.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QAy8brFG9x8fXet3AV9dJE

* docs(changelog): record the comment rewrite, and say why og:type is "website"

CONTRIBUTING.md requires every change to land an `## [Unreleased]` entry;
this PR had none. Added under `### Changed`, with the scope, the
comment-only property and the seven string edits it rests on.

app/seo.ts: `og:type "website"` was left without a reason when the Sphinx
reference came off, and "article" is the choice a future reader would reach
for on a page with a byline. Records why it is not: the lectures carry none
of the `article:*` metadata that choice commits to, and the deployed sites
declare "website" throughout (which is what the comment on `main` said).

Both raised in review on this PR -- the CHANGELOG entry by the Copilot
reviewer, the og:type reason by quantecon-services.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QAy8brFG9x8fXet3AV9dJE

* Revert "docs(changelog): record the comment rewrite"

Reverts the CHANGELOG half of b8b8101, keeping the app/seo.ts og:type
comment it also carried.

I added the entry on a Copilot review flag citing CONTRIBUTING's
"add your change to `## [Unreleased]`" rule, without checking whether this
PR had already taken a position. It had: the description says no entry,
because like #175 -- which corrected the FOUC comments and touched no
CHANGELOG -- this changes nothing a consumer of the theme can see. Verified
that 1aae2c8 (#175) indeed has no CHANGELOG entry, so the cited precedent
holds. The author's call stands.

It was also the sole cause of the merge conflict against main: #199 released
v2.7.0, moving the `## [Unreleased]` entries this added to into `## [2.7.0]`.
With the entry gone the PR no longer touches CHANGELOG.md at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QAy8brFG9x8fXet3AV9dJE

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

3 participants