Skip to content

fix: stop the outline's first-paint correction animating, and desensitise the FOUC control - #141

Closed
mmcky wants to merge 1 commit into
mainfrom
fix/first-paint-transitions
Closed

fix: stop the outline's first-paint correction animating, and desensitise the FOUC control#141
mmcky wants to merge 1 commit into
mainfrom
fix/first-paint-transitions

Conversation

@mmcky

@mmcky mmcky commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Two pieces of the same first-paint family, shipped together because they share a mechanism.

Important

Rescoped after #144. This PR originally also fixed the contents toggle icons animating their own first-paint correction (#127). #144 rebuilds the drawer on the Popover API and swaps the icons with display off :popover-open, so there is no transition-all, no useMounted and nothing to correct — that half is obsolete. Once #144 lands this branch will be rebased to drop the SidebarToggle.tsx / ContentsSidebar.tsx changes. The two remaining pieces below are untouched by #144 and still wanted.

Outline / BackToTop animates its first-paint correction

Outline.BackToTop renders with a transition and an opacity-driven visibility, so on the pre-app.css frame of a static-build load it paints at full opacity and then fades out once the stylesheet lands. It now withholds the transition until after mount — the correction becomes instant, and the animation is reserved for real state changes. useMounted moves into app/hooks/useMounted.tsx; after the rebase Outline will be its only consumer, which is still worth a shared hook given the drawer used to need it too and future components may.

Note

The toolbar is broadly unstyled on that frame anyway (fixed top-0 h-[50px] is not in CRITICAL_CSS), so the fade was the tail of a larger snap. Whether to extend the critical CSS to cover the toolbar is a bigger, separate question and is not touched here.

#126 step 2 — the FOUC control is racy

Hydration currently fails on every page load (React #418/#423), and the #423 recovery re-renders the tree, which puts the inlined critical <style> back — around 195ms in. The control test strips that block from the served HTML precisely to prove the guard is meaningful, so when React restores it, all three control assertions flip at once. The measurement ran from the test after domcontentloaded, leaving roughly a 150ms budget; on a loaded CI runner that is a PR-gating job going red for reasons that have nothing to do with the critical CSS.

The measurement now happens in an init script that fires in-page on DOMContentLoaded, before hydration is even scheduled. Chosen over the "bail early" alternative because a control that strips the inline block guarantees a hydration mismatch by construction — so no repair to #126 step 1 could ever make a post-hydration sample safe here. The ordering is empirical rather than guaranteed: Remix v1 emits its entry as an inline type="module" async script whose imports must resolve before entry.client.tsx even schedules requestIdleCallback/setTimeout for hydrateRoot, and WebKit dispatches DOMContentLoaded at end of parse. The earliest restoration observed in #126 is an order of magnitude later than the sample.

A cheap version of the bail is folded in anyway: both cases assert their own preconditions. The strip now reports whether it actually matched anything, rather than the test inferring it from the same marker the strip uses — otherwise a reshaped CRITICAL_CSS would make the strip a silent no-op and the control would fail with "expected block, received grid", pointing at the critical CSS rather than at the stale pattern actually at fault.

Only step 2. Root-causing the hydration mismatch is the real work and #126 stays open for it.

Note that #144 also rewrites tests/visual/fouc.spec.ts (the drawer assertions become "paints nothing in both cases"), so the init-script change here will need to be re-applied on top of that version of the file.

Verification (pre-rescope; to be re-run after the rebase)

The important one: the guard still guards. Removing the grid rule from CRITICAL_CSS and rebuilding fails the main test with Expected: "grid" / Received: "block", while the control still passes — so the hardening desensitised it to hydration without desensitising it to regressions.

Otherwise: npm run compile clean · test:fouc 2 passed · test:unit 13/13 · full visual suite 19 passed, 0 failed with baselines untouched.

Relates to #126 and #127 (the #127 fix itself now lands via #144).

🤖 Generated with Claude Code

Copilot AI lite review requested due to automatic review settings August 20, 2026 06:01
@github-actions

github-actions Bot commented Aug 20, 2026

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-141/

Built to branch gh-pages at 2026-08-26 01:49 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

@github-actions

github-actions Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

🎭 Visual regression results

passed  17 passed
skipped  3 skipped

Details

stats  20 tests across 1 suite
duration  37.5 seconds
commit  c18831c

Skipped tests

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.

Pull request overview

This PR addresses first-paint (pre-app.css) corrections that were unintentionally animating, and hardens the WebKit FOUC guard test so its control case isn’t racy due to known hydration failure/recovery behavior.

Changes:

  • Adds a shared useMounted hook and uses it to withhold transition classes until after mount in ContentsSidebar, SidebarToggle, and BackToTop.
  • Narrows SidebarToggle’s transition from transition-all to an explicit property list (opacity, transform, color) to avoid unintended first-paint animations.
  • Updates the WebKit FOUC guard to sample state via an init script at DOMContentLoaded, and adds explicit precondition assertions for the control strip.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated no comments.

Show a summary per file
File Description
tests/visual/fouc.spec.ts Moves sampling into an init script and adds control precondition probes to reduce flakiness under hydration recovery.
CHANGELOG.md Documents the first-paint transition fixes and the FOUC test hardening.
app/hooks/useMounted.tsx Introduces shared “mounted” gating hook for deferring transitions until after mount.
app/components/toolbar/SidebarToggle.tsx Defers icon transitions until after mount and narrows transition properties to avoid first-paint fade-outs.
app/components/Outline.tsx Defers BackToTop opacity transition until after mount to avoid first-paint fade correction.
app/components/ContentsSidebar.tsx Replaces inline hook with shared useMounted import.

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

@mmcky mmcky changed the title fix: stop first-paint corrections animating, and desensitise the FOUC control fix: stop the outline's first-paint correction animating, and desensitise the FOUC control Aug 22, 2026
@mmcky

mmcky commented Aug 22, 2026

Copy link
Copy Markdown
Contributor Author

Rescoped following review of #144: the #127 half (toggle icons animating their correction) is made obsolete there, so this PR is reduced to the Outline/BackToTop fix and the FOUC-control hardening. Title and description updated to match. Plan: merge #144 first, then rebase this branch to drop the SidebarToggle.tsx and ContentsSidebar.tsx changes and re-apply the fouc.spec.ts init-script change on top of #144's version of that file.

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.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.

@mmcky
mmcky force-pushed the fix/first-paint-transitions branch 3 times, most recently from d639383 to 76d1ebd Compare August 26, 2026 01:23
… control

Two halves of the same first-paint family.

the contents panel; the button that drives it has the identical shape and
was untouched. Both lucide icons carry `transition-all` with visibility
driven by opacity, and on the pre-app.css frame none of `absolute`,
`opacity-0` or `opacity-100` exists while lucide still emits real
width/height attributes — so both paint in flow at full opacity. When the
stylesheet lands `position` snaps, since it is not animatable, but
`opacity` 1 -> 0 animates: a close icon fading out of the toolbar on
every static-build navigation. "Back to top" in the page margin has the
same shape.

Both now withhold the transition until after mount, which makes the
correction instant and reserves the animation for real state changes.
The toggle's `transition-all` is narrowed to opacity, transform and
colour — the three that were ever meant to move; colour is kept
explicitly so the icons still ease across a dark-mode toggle rather than
snapping. `useMounted` is hoisted out of ContentsSidebar into
app/hooks/ so all three sites share one definition.

(React #418/#423) and the recovery re-render restores the inlined
critical <style> around 195ms, after the control has stripped it from the
served HTML precisely to prove the guard is meaningful. The measurement
ran from the test after domcontentloaded, roughly a 150ms budget, so on a
loaded runner all three control assertions flipped at once and a PR-
gating job went red for no real reason.

It now samples from an init script that fires in-page on
DOMContentLoaded, before hydration is even scheduled. This survives a
fixed #126 as well: stripping the inline block guarantees a hydration
mismatch by construction, so no repair to the hydration failure itself
could make a post-hydration sample safe here.

Both cases also assert their own preconditions — that the strip actually
matched something, and whether the inline block is present as sampled —
so a reshaped CRITICAL_CSS reports as a stale strip pattern rather than
as three confusing failures about grid layout.

Verified the guard still guards: removing the grid rule from CRITICAL_CSS
fails the main test with `Expected "grid", Received "block"`, and the
control still passes. Settled-state pixels are unchanged — the full
visual suite passes against untouched baselines.

The hydration failure itself (#126 step 1) stays open.

Closes #127

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@mmcky
mmcky force-pushed the fix/first-paint-transitions branch from 76d1ebd to c18831c Compare August 26, 2026 01:48
@mmcky

mmcky commented Aug 26, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto current main (post-#151) and relocated this PR's two changelog entries, which had silently drifted into the wrong section.

Worth explaining, because git reported the merge as clean either way. When this branch was last rebased, its entries sat under ## [Unreleased]. #149 then moved that whole section under the dated ## [2.3.1] heading. Git's three-way merge still matched the surrounding context — the ### Fixed bullet these lines follow — so a merge succeeded and filed both entries inside the already-released 2.3.1 section. I confirmed it with a test merge before rebasing:

2.3.1-RELEASED: - The sidebar toggle icons no longer animate their first-pai
2.3.1-RELEASED: - The WebKit FOUC guard no longer goes red for reasons unrel

That would have been invisible and permanent: v2.3.1's release notes were generated at tag time and would never gain these lines, while the next release would ship both fixes with no changelog entry at all. It is the same failure #83 hit against the tagged [2.2.0] section, which needed #132 to repair.

Both bullets now sit under [Unreleased]### Fixed, alongside the #150 entry that landed with #151. Verified that the released section is untouched: the ## [2.3.1] block is byte-identical to main, and git diff origin/main -- CHANGELOG.md contains no deletions — only additions under [Unreleased].

No code changes in this push. npm run compile clean · npm run test:unit 20/20.

General note for the remaining stacked PRs: now that [Unreleased] has been emptied by a release, any branch whose changelog entries predate #149 will land them in the frozen 2.3.1 section without conflicting. Worth checking placement after rebasing rather than trusting a clean merge. #144 is unaffected — it touches no changelog file, though a drawer rebuild is user-facing enough that it probably wants an entry before it merges.

@mmcky

mmcky commented Sep 5, 2026

Copy link
Copy Markdown
Contributor Author

Closing in favour of #168 and #169, after re-measuring this against current main (post-#144, v2.4.0).

The Outline / BackToTop half turned out not to work. Sampling .qe-back-to-top's computed opacity every frame in WebKit against a make build-theme bundle of main, with app.css delayed by 400ms: the first painted frame was already styled, so WebKit blocks first paint on the head stylesheets in this harness and there is no pre-app.css initial frame to correct. The unstyled frame comes about 200ms after DOMContentLoaded, when the React #423 hydration recovery (#126) re-renders the head and briefly drops the stylesheet. By then the component has mounted and useMounted has already re-added the transition, so when the sheet re-applies the button fades out exactly as before. With this branch's Outline.tsx change applied: 17–18 animating frames, identical to main. A single zero-specificity rule in the critical CSS, which is restored with the tree on that re-render, brings it to zero. That is #168, following the pattern #144 set for the close icon.

The FOUC-control half is still right and is carried onto the current probes (sidebarRendered, toggleCloseRendered, plus #168's backToTopOpacity) in #169, with the same init-script sampling and precondition assertions and the same guard-still-guards verification.

Also noting for the record: all five files here conflict with main now, the SidebarToggle.tsx / ContentsSidebar.tsx changes were made obsolete by #144, and the two changelog entries had drifted into the now-frozen [2.4.0] section, the same failure described in the comment above. #127 is closed by #144's rebuild; #126 stays open as the root cause, and the measurement above suggests fixing it removes this whole family of animations at the source.

@mmcky mmcky closed this Sep 5, 2026
mmcky added a commit that referenced this pull request Sep 5, 2026
BackToTop is hidden by opacity-0 and carries transition-opacity, so on any
frame where the stylesheet is absent it paints at full opacity and fades out
once the sheet lands. That frame is produced by the React #423 hydration
recovery (#126), which re-renders the head and briefly drops the stylesheet
after the component has mounted — so gating the transition on mount, as #141
tried, cannot prevent it. Measured in WebKit with the stylesheet delayed:
17-18 animating frames without this rule, none with it.

The FOUC guard asserts the rule: the main test expects opacity 0, the control
expects 1 with the inline block stripped.

Supersedes the Outline.tsx change in #141. Relates to #126.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
mmcky added a commit that referenced this pull request Sep 5, 2026
…econditions (#169)

* fix: pin "back to top" to opacity 0 in the critical CSS

BackToTop is hidden by opacity-0 and carries transition-opacity, so on any
frame where the stylesheet is absent it paints at full opacity and fades out
once the sheet lands. That frame is produced by the React #423 hydration
recovery (#126), which re-renders the head and briefly drops the stylesheet
after the component has mounted — so gating the transition on mount, as #141
tried, cannot prevent it. Measured in WebKit with the stylesheet delayed:
17-18 animating frames without this rule, none with it.

The FOUC guard asserts the rule: the main test expects opacity 0, the control
expects 1 with the inline block stripped.

Supersedes the Outline.tsx change in #141. Relates to #126.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(fouc): sample first paint from an init script, assert control preconditions

The React #423 hydration recovery restores the inlined critical <style>
150-240ms after DOMContentLoaded (#126). The control strips that block from
the served HTML, so a post-domcontentloaded sample can see it restored and
every control assertion flips at once. Sample in-page on DOMContentLoaded
instead, before hydration is scheduled, and have both cases assert their own
preconditions so a reshaped CRITICAL_CSS reports as a stale strip pattern.

Carries the test half of #141 onto the post-#144 probes. Relates to #126.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
@mmcky
mmcky deleted the fix/first-paint-transitions branch September 10, 2026 22:56
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