Skip to content
Merged
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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
opacity. Measured with the stylesheet delayed: 17–18 animating frames before,
none after. Supersedes the `useMounted` approach in
[#141](https://github.com/QuantEcon/quantecon-theme.mystmd/pull/141).
- 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>` 150–240ms after
`DOMContentLoaded` — 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`, so on a loaded runner every control
assertion 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 a run of confusing failures
about grid layout. The underlying hydration failure remains open in
[#126](https://github.com/QuantEcon/quantecon-theme.mystmd/issues/126);
carried over from [#141](https://github.com/QuantEcon/quantecon-theme.mystmd/pull/141).

### Dependencies
- `@fontsource/pt-serif` 5.3.0 self-hosts the PT Serif heading face (400/700,
Expand Down
163 changes: 124 additions & 39 deletions tests/visual/fouc.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,18 @@ import { test, expect, type Page, type Route } from "@playwright/test";

const PAGE = "/";

/**
* 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 @@ -39,55 +50,114 @@ 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;
sidebarRendered: boolean | null;
toggleCloseRendered: boolean | null;
backToTopOpacity: string | 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 — measured at 150–240ms
* after `DOMContentLoaded`. 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-toc");
const closeIcon = document.querySelector(".qe-toc-toggle__close");
const backToTop = document.querySelector(".qe-back-to-top");
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: FirstPaint = {
gridDisplay: grid ? getComputedStyle(grid).display : "(absent)",
bodyFont: getComputedStyle(document.body).fontFamily,
// A closed popover is `display: none` per the UA stylesheet, so it
// paints nothing even with no author CSS at all — hence both tests
// below expect it hidden.
//
// `null` when the element is missing, so a renamed hook fails the
// explicit presence guard instead of silently satisfying "not
// rendered".
sidebarRendered: sidebar ? sidebar.getClientRects().length > 0 : null,
// The toggle's close icon is hidden by a critical-CSS rule; without
// it both icons paint side by side on the first frame. `null` when
// missing, for the same reason as above.
toggleCloseRendered: closeIcon ? closeIcon.getClientRects().length > 0 : null,
// "Back to top" is hidden by opacity rather than display, and
// carries a transition — so if it paints visible here it will *fade*
// out once the stylesheet lands. The critical block pins it to 0.
// `null` when missing, as above.
backToTopOpacity: backToTop ? getComputedStyle(backToTop).opacity : null,
// null href == an inline <style>; any string == an external sheet
// that applied
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-toc");
const closeIcon = document.querySelector(".qe-toc-toggle__close");
const backToTop = document.querySelector(".qe-back-to-top");
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,
// A closed popover is `display: none` per the UA stylesheet, so it paints
// nothing even with no author CSS at all — hence both tests below expect
// it hidden.
//
// `null` when the element is missing, so a renamed hook fails the
// explicit presence guard instead of silently satisfying "not rendered".
sidebarRendered: sidebar ? sidebar.getClientRects().length > 0 : null,
// The toggle's close icon is hidden by a critical-CSS rule; without it
// both icons paint side by side on the first frame. `null` when missing,
// for the same reason as above.
toggleCloseRendered: closeIcon ? closeIcon.getClientRects().length > 0 : null,
// "Back to top" is hidden by opacity rather than display, and carries a
// transition — so if it paints visible here it will *fade* out once the
// stylesheet lands. The critical block pins it to 0. `null` when missing,
// as above.
backToTopOpacity: backToTop ? getComputedStyle(backToTop).opacity : 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 });
return page.evaluate<FirstPaint, string>((key) => (window as any)[key], SAMPLE_KEY);
}

test.describe("FOUC guard (WebKit) — inline critical CSS styles the first paint", () => {
Expand All @@ -97,6 +167,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 Down Expand Up @@ -125,9 +199,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 a run of 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