Skip to content
Closed
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
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
directory rather than assuming one. A production build now emits **zero**
absolute asset URLs and all 78 references resolve
([#150](https://github.com/QuantEcon/quantecon-theme.mystmd/issues/150)).
- The sidebar toggle icons no longer animate their first-paint correction.
#123 stopped the contents panel flashing open on static-build loads; the
button that drives it had 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 icons
paint in flow at full opacity, and when the stylesheet lands `position` snaps
but `opacity` 1 → 0 *animates*, fading a close icon out of the toolbar on
every navigation. The transition is now withheld until after mount, making
that correction instant, and narrowed from `transition-all` to the three
properties that were ever meant to move (opacity, transform for
`hover:scale-110`, and colour so the icons still ease across a dark-mode
toggle). "Back to top" in the page margin had the same shape and is covered
too. The `useMounted` hook #123 introduced is hoisted to `app/hooks/` and
shared by all three sites
([#127](https://github.com/QuantEcon/quantecon-theme.mystmd/issues/127)).
- The WebKit FOUC guard no longer goes red for reasons unrelated to the critical
CSS. Hydration currently fails on every page load (React #418/#423), and the
recovery re-render restores the inlined critical `<style>` about 195ms in —
after the control test has deliberately stripped it from the served HTML to
prove the guard is meaningful. The measurement used to run from the test after
`domcontentloaded`, leaving roughly a 150ms budget, so on a loaded runner all
three control assertions flipped at once. It now samples from an init script
that fires in-page on `DOMContentLoaded`, before hydration is even scheduled.
Both cases also assert their own preconditions — that the strip actually
matched, and whether the inline block is present as sampled — so a reshaped
`CRITICAL_CSS` reports as a stale strip pattern instead of as three confusing
failures about grid layout. The underlying hydration failure remains open in
[#126](https://github.com/QuantEcon/quantecon-theme.mystmd/issues/126).

## [2.3.1] - 2026-08-26

Expand Down
15 changes: 1 addition & 14 deletions app/components/ContentsSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
import { useSidebarHeight } from '@myst-theme/site';
import classNames from 'classnames';
import { slugToUrl } from 'myst-common';
import { useEffect, useState } from 'react';
import useMounted from '~/hooks/useMounted';

type StrictHeading = Omit<Heading, 'level'> & { level: number };
type HeadingGroup = StrictHeading[];
Expand Down Expand Up @@ -44,19 +44,6 @@ function Section({ group }: { group: HeadingGroup }) {
);
}

/**
* True only after the component has mounted on the client.
*
* Both the server render and the first (hydrating) client render return
* `false`, so the markup matches and React does not warn; the effect then
* flips it on the frame after hydration.
*/
function useMounted() {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
return mounted;
}

export function ContentsSidebar() {
const [open] = useNavOpen();
const mounted = useMounted();
Expand Down
9 changes: 8 additions & 1 deletion app/components/Outline.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,24 @@
import { useBaseurl, useLinkProvider, withBaseurl } from '@myst-theme/providers';
import { useHeaders } from '@myst-theme/site';
import classNames from 'classnames';
import useMounted from '~/hooks/useMounted';
import useScroll from '~/hooks/useScroll';

export function BackToTop() {
const isScrolled = useScroll(80);
const mounted = useMounted();
const Link = useLinkProvider();
return (
<div className="fixed bottom-0 left-0 right-0 col-screen not-prose simple-center-grid grid-gap">
<div className="relative col-margin">
<p
className={classNames(
'absolute bottom-0 font-semibold text-md z-[1000] transition-opacity ease-in-out duration-300',
'absolute bottom-0 font-semibold text-md z-[1000]',
// Withheld until after mount: before app.css lands `opacity-0` does
// not exist, so this paints visible and would then *fade* out when
// the stylesheet arrives rather than simply never having been
// there. Same mechanism as ContentsSidebar and SidebarToggle.
mounted && 'transition-opacity ease-in-out duration-300',
{
'opacity-100': isScrolled,
'opacity-0': !isScrolled,
Expand Down
36 changes: 28 additions & 8 deletions app/components/toolbar/SidebarToggle.tsx
Original file line number Diff line number Diff line change
@@ -1,28 +1,48 @@
import { useNavOpen } from '@myst-theme/providers';
import classNames from 'classnames';
import { Menu, X } from 'lucide-react';
import useMounted from '~/hooks/useMounted';

export function SidebarToggle() {
const [open, setOpen] = useNavOpen();
const mounted = useMounted();

// Both icons are always rendered, stacked by `absolute` and cross-faded by
// opacity.
//
// The transition is withheld until after mount for the same reason as the
// panel this button drives (see ContentsSidebar.tsx). On a static build the
// first paint happens before app.css applies, so neither `absolute` nor
// `opacity-0` exists yet and both icons paint in flow at full opacity —
// lucide emits real width/height attributes, so they have size without any
// CSS. When app.css lands `position` snaps, since it is not animatable, but
// `opacity` 1 → 0 would *animate*: a close icon fading out of the toolbar on
// every navigation. Withholding the transition until mount makes that
// correction instant and leaves the cross-fade for real clicks.
//
// Narrowed from `transition-all`, which animated every property app.css
// changes on that frame. The three named here are the ones that were ever
// meant to move: opacity for the cross-fade, transform for `hover:scale-110`,
// and colour so the icons still ease across a dark-mode toggle.
const iconClasses = (visible: boolean) =>
classNames('absolute hover:scale-110', mounted && 'transition-[opacity,transform,color] duration-300 ease-in-out', {
'opacity-100': visible,
'opacity-0': !visible,
});

return (
<button
className="relative flex items-center w-6 h-6 cursor-pointer opacity-90"
onClick={() => setOpen(!open)}
>
<X
className={classNames('absolute transition-all duration-300 ease-in-out hover:scale-110', {
'opacity-0': !open,
'opacity-100': open,
})}
className={iconClasses(open)}
width={24}
height={24}
aria-label="Hide table of contents"
/>
<Menu
className={classNames('absolute transition-all duration-300 ease-in-out hover:scale-110', {
'opacity-100': !open,
'opacity-0': open,
})}
className={iconClasses(!open)}
width={24}
height={24}
aria-label="Show table of contents"
Expand Down
23 changes: 23 additions & 0 deletions app/hooks/useMounted.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { useEffect, useState } from 'react';

/**
* True only after the component has mounted on the client.
*
* Both the server render and the first (hydrating) client render return
* `false`, so the markup matches and React does not warn; the effect then
* flips it on the frame after hydration.
*
* Used to withhold CSS transitions until the stylesheet has landed. On a static
* build the first paint happens before `app.css` applies, so any state the
* stylesheet then corrects — an `opacity-0`, a `-translate-x-full` — would
* *animate* into place if the `transition-*` class predated it. Gating the
* transition on this makes that correction instant, and reserves the animation
* for genuine, post-hydration state changes.
*/
const useMounted = () => {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
return mounted;
};

export default useMounted;
139 changes: 106 additions & 33 deletions tests/visual/fouc.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,18 @@ const PAGE = "/";
*/
const OFFSCREEN_EPS = 1;

/**
* Aborts external stylesheets, optionally stripping the inline critical block.
*
* Returns a `strippedCritical` probe rather than trusting the regex to have
* matched. If `CRITICAL_CSS` is ever reshaped so the pattern stops matching,
* the control would otherwise run against a fully-styled page and fail on its
* *assertions* — reporting "expected block, received grid", which points at the
* critical CSS rather than at the stale pattern that is actually at fault.
* Asserting this probe turns that into a failure that names itself.
*/
async function isolateInlineCss(page: Page, { stripCritical = false } = {}) {
const probe = { strippedCritical: false };
await page.route("**/*", async (route: Route) => {
const req = route.request();

Expand All @@ -54,49 +65,96 @@ async function isolateInlineCss(page: Page, { stripCritical = false } = {}) {
// Control: serve the document with the inlined critical <style> removed.
if (req.resourceType() === "document" && stripCritical) {
const response = await route.fetch();
const body = (await response.text()).replace(
const served = await response.text();
const body = served.replace(
/<style>[^<]*:where\(\.simple-center-grid\)[^<]*<\/style>/g,
"<!-- critical CSS removed for control -->",
);
if (body !== served) probe.strippedCritical = true;
return route.fulfill({ response, body });
}

return route.continue();
});
return probe;
}

async function firstPaintState(page: Page) {
/**
* Key under which the in-page sampler parks its measurement on `window`.
*/
const SAMPLE_KEY = "__qeFirstPaint";

type FirstPaint = {
gridDisplay: string;
bodyFont: string;
sidebarRight: number | null;
appliedExternal: boolean;
criticalInline: boolean;
};

/**
* Measure at first paint, from inside the page, before React hydrates.
*
* The measurement used to run from the test after `domcontentloaded`, which
* left roughly a 150ms budget before hydration. That is not enough: hydration
* currently fails on every load (React #418/#423, tracked in #126), and the
* recovery re-render puts the critical `<style>` back — around 195ms in
* sampling. The control strips that block from the served HTML precisely to
* prove the guard is meaningful, so when React restored it the control's
* assertions all flipped at once and `fouc-guard` went red for reasons that had
* nothing to do with the critical CSS.
*
* Sampling from an init script closes that window: it runs on
* `DOMContentLoaded`, in-page and synchronously, and it reads only the DOM. 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 — but
* that ordering is what the #126 timings show, with the earliest observed
* restoration an order of magnitude later than the sample.
*
* Note this deliberately survives a *fixed* #126: a control that strips 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.
*/
async function firstPaintState(page: Page): Promise<FirstPaint> {
await page.addInitScript((key) => {
document.addEventListener(
"DOMContentLoaded",
() => {
const grid = document.querySelector(".simple-center-grid");
const sidebar = document.querySelector(".qe-contents-sidebar");
const applied = Array.from(document.styleSheets).filter((sheet) => {
try {
return !!sheet.cssRules && sheet.cssRules.length > 0; // applied, not pending
} catch {
return false; // cross-origin / not yet loaded
}
});
const sample = {
gridDisplay: grid ? getComputedStyle(grid).display : "(absent)",
bodyFont: getComputedStyle(document.body).fontFamily,
sidebarRight: sidebar ? sidebar.getBoundingClientRect().right : null,
appliedExternal: applied.some((sheet) => !!sheet.href),
// Whether the inline block is present in the document as sampled —
// the state the assertions below are actually about.
criticalInline: Array.from(document.querySelectorAll("style")).some((el) =>
(el.textContent ?? "").includes(":where(.simple-center-grid)"),
),
};
(window as unknown as Record<string, unknown>)[key] = sample;
},
{ once: true },
);
}, SAMPLE_KEY);

await page.goto(PAGE, { waitUntil: "domcontentloaded" });
await page.waitForSelector(".simple-center-grid", { timeout: 5000 }).catch(() => {});
return page.evaluate(() => {
const grid = document.querySelector(".simple-center-grid");
const sidebar = document.querySelector(".qe-contents-sidebar");
const applied = Array.from(document.styleSheets).filter((sheet) => {
try {
return !!sheet.cssRules && sheet.cssRules.length > 0; // applied, not pending
} catch {
return false; // cross-origin / not yet loaded
}
});
return {
gridDisplay: grid ? getComputedStyle(grid).display : "(absent)",
bodyFont: getComputedStyle(document.body).fontFamily,
// The nav panel is closed on load, so it must start parked off-screen to
// the left: its right edge at (or left of) the viewport's left edge. If
// any real width of it is on-screen here, the menu visibly flashes open.
//
// Reported as the measured edge rather than a boolean so the assertions
// can carry a sub-pixel tolerance — see `OFFSCREEN_EPS` below.
//
// `null` when the element is missing, and the guards below reject it
// explicitly: `null` numerically coerces to 0, which would *pass* the
// off-screen check, so a renamed hook would slip through the main test
// and surface as a confusing failure in the control instead.
sidebarRight: sidebar ? sidebar.getBoundingClientRect().right : null,
// null href == an inline <style>; any string == an external sheet that applied
appliedExternal: applied.some((sheet) => !!sheet.href),
};
});
await page.waitForFunction((key) => !!(window as any)[key], SAMPLE_KEY, { timeout: 5000 });
const state = await page.evaluate<FirstPaint, string>(
(key) => (window as any)[key],
SAMPLE_KEY,
);
return state;
}

test.describe("FOUC guard (WebKit) — inline critical CSS styles the first paint", () => {
Expand All @@ -106,6 +164,10 @@ test.describe("FOUC guard (WebKit) — inline critical CSS styles the first pain

// No external sheet applied, so anything styled below comes from inline CSS.
expect(state.appliedExternal).toBe(false);
expect(
state.criticalInline,
"the inline critical <style> is missing from the page as served — app/root.tsx no longer inlines CRITICAL_CSS"
).toBe(true);
// The reported FOUC symptoms must be absent on first paint:
expect(state.gridDisplay).toBe("grid"); // grid not collapsed to block
// Head of the stack, not just a substring: "Source Sans 3 Variable" (the
Expand All @@ -125,9 +187,20 @@ test.describe("FOUC guard (WebKit) — inline critical CSS styles the first pain
});

test("control: removing the inline critical CSS reproduces the FOUC", async ({ page }) => {
await isolateInlineCss(page, { stripCritical: true });
const probe = await isolateInlineCss(page, { stripCritical: true });
const state = await firstPaintState(page);

// Preconditions first, so a stale strip pattern reports as itself rather
// than as three confusing assertion failures about the critical CSS.
expect(
probe.strippedCritical,
"the strip pattern matched nothing in the served HTML — CRITICAL_CSS was reshaped and the regex in isolateInlineCss needs updating"
).toBe(true);
expect(
state.criticalInline,
"the inline critical <style> is present despite the strip — it was restored before the sample (see #126)"
).toBe(false);

// With no CSS at all, the page is unstyled — this proves the guard above is
// meaningful (the external abort really does strip styling).
expect(state.appliedExternal).toBe(false);
Expand Down
Loading