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
74 changes: 63 additions & 11 deletions apps/website/e2e/home-bundle.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,15 @@ const REGISTRY_MARKER = 'LangGraph Durable Execution (Python)';
const DOCS_ROUTE = '/docs/langgraph/guides/streaming';

/**
* The fonts the homepage's above-the-fold hero actually renders: Archivo Black
* (the H1), Archivo normal and italic (one next/font instance, so they preload
* together), and JetBrains Mono (the eyebrow and the demo's URL bar). A fifth
* preload is a new High-priority request racing the LCP image on a phone — add
* one only after measuring that it is above the fold.
* The fonts the homepage preloads, identified by family and style. See the
* FONT PRELOADS note in app/layout.tsx for the measurements behind this list:
* on a throttled phone every preloaded font races the hero poster for
* bandwidth, so only faces whose late arrival would visibly hurt are here.
* Archivo italic and JetBrains Mono were measured at -228ms and -224ms of LCP
* each; Archivo normal stays because un-preloading it reflowed the subhead
* (CLS 0.206). Change this list only with a measurement.
*/
const MAX_FONT_PRELOADS = 4;
const EXPECTED_PRELOADS = ['Archivo Black normal', 'Archivo normal'];

/**
* The bodies of the scripts `path` itself requires: the `<script src>` tags and
Expand Down Expand Up @@ -79,10 +81,60 @@ test.describe('homepage bundle', () => {
).toBe(true);
});

test('the homepage preloads only the fonts the hero renders', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded' });
const preloads = await page.locator('link[rel="preload"][as="font"]').count();
expect(preloads).toBeGreaterThan(0);
expect(preloads).toBeLessThanOrEqual(MAX_FONT_PRELOADS);
test('the homepage preloads only the H1 face and the body face', async ({ page }) => {
// Read the live DOM, not the server HTML: under `next dev` React injects
// the font preloads into <head> during hydration, while a production build
// writes them into the HTML. The DOM has them either way.
await page.goto('/', { waitUntil: 'load' });
const faces = await page.evaluate(() => {
const files = [...document.querySelectorAll<HTMLLinkElement>('link[rel="preload"][as="font"]')].map(
(link) => new URL(link.href).pathname.split('/').pop() ?? ''
);
// Resolve each preloaded file to its @font-face through the CSSOM: a bare
// count would pass with the wrong face preloaded, e.g. the italic.
const rules: CSSFontFaceRule[] = [];
for (const sheet of [...document.styleSheets]) {
let list: CSSRuleList;
try {
list = sheet.cssRules;
} catch {
continue; // a cross-origin sheet; ours are same-origin
}
for (const rule of [...list]) if (rule instanceof CSSFontFaceRule) rules.push(rule);
}
return files.map((file) => {
const rule = rules.find((r) => r.style.getPropertyValue('src').includes(file));
const family = rule?.style.getPropertyValue('font-family').replace(/["']/g, '').trim();
const style = rule?.style.getPropertyValue('font-style').trim() || 'normal';
return `${family} ${style}`;
});
});
expect(faces.sort()).toEqual([...EXPECTED_PRELOADS].sort());
});

test('the unpreloaded italic still merges into the Archivo family', async ({ page }) => {
// Archivo's italic is its own next/font instance so it can skip the
// preload. That only works because both instances register the real
// family name, `Archivo`, and the browser merges them: italic text then
// picks the true italic. If the italic ended up under any other family,
// this load would find no italic face and every italic would turn faux.
await page.goto('/');
const faces = await page.evaluate(async () =>
(await document.fonts.load('italic 400 16px Archivo')).map((f) => `${f.family.replace(/["']/g, '')} ${f.style}`)
);
expect(faces).toContain('Archivo italic');
});

test('monospace text never falls back to a proportional font', async ({ page }) => {
// JetBrains Mono is not preloaded, so its fallback is visible while it
// loads. next/font's generated fallback for it is `local(Arial)`, which
// would misalign code; layout.tsx replaces it with a monospace stack.
await page.goto('/');
const stack = await page.evaluate(() =>
getComputedStyle(document.documentElement).getPropertyValue('--font-mono')
);
expect(stack).toContain('ui-monospace');
expect(stack).toContain('monospace');
expect(stack).not.toContain('JetBrains Mono Fallback');
});
});
62 changes: 57 additions & 5 deletions apps/website/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,21 +24,64 @@ import { PreAnalyticsBeacon } from '../components/shared/PreAnalyticsBeacon';
import { EngagedTimeSignal } from '../components/shared/EngagedTimeSignal';
import { websiteContentCatalog } from '../lib/growth/website-content';

/*
* FONT PRELOADS. Exactly two faces are preloaded — Archivo Black and Archivo
* normal — and that is deliberate.
*
* The homepage's LCP element is the hero poster, an image, so no font gates
* LCP: every face here is font-display: swap. But on a throttled phone the CDN
* splits bandwidth between concurrent requests rather than honoring priority,
* so every preloaded font is a High-priority request racing the poster.
* Measured on production (Lighthouse mobile, devtools throttling), blocking
* Archivo italic moved LCP -228ms and JetBrains Mono -224ms — each more than
* PostHog's whole library. Unpreloaded faces still load as soon as text using
* them lays out, behind a fallback.
*
* Why these two stay preloaded:
* - Archivo Black is the H1 (9.8KB): the one face whose late arrival a visitor
* would plainly see.
* - Archivo normal is the hero subhead and CTAs. Un-preloading it was tried
* and measured: the subhead wraps to two lines in the Arial-based fallback
* and three in Archivo, so the swap pushed the CTAs and the whole demo down
* — CLS 0.206 on a 375x812 phone. Keep it preloaded unless that reflow is
* solved first.
*
* e2e/home-bundle.spec.ts asserts exactly these two preloads.
*/
const display = Archivo_Black({
subsets: ['latin'],
weight: '400',
variable: '--font-display',
});

/*
* Archivo is split into two next/font instances so the italic can skip the
* preload: next/font preloads per instance, all styles together.
*
* The split costs nothing at the call sites because next/font registers both
* under the REAL family name, `Archivo` (checked in the built CSS). The browser
* merges them into one family, so every `font-style: italic` on text set in
* var(--font-sans) still selects the true italic face, not a synthesized
* oblique — no CSS needs to know the italic lives in its own instance.
* `--font-sans-italic` exists only so the instance is referenced and its
* @font-face ships. If a future next/font ever hashes family names again, the
* two would become separate families and italics would silently turn faux;
* e2e/home-bundle.spec.ts loads the italic by family name to catch that.
*/
const sans = Archivo({
subsets: ['latin'],
// Archivo ships a true italic. Loading it makes every italic on the text
// face real rather than an obliqued upright — Inter was normal-only here,
// so those sites had been faux-italic all along.
style: ['normal', 'italic'],
style: ['normal'],
variable: '--font-sans',
});

const sansItalic = Archivo({
subsets: ['latin'],
style: ['italic'],
variable: '--font-sans-italic',
// 38.5KB, and no italic is set above the fold on any page.
preload: false,
});

/**
* Inter is retained for diagrams only, and MUST be loaded here rather than
* left to theme.css: next/font registers its family under a hashed name, so
Expand All @@ -61,6 +104,15 @@ const diagram = Inter({
const mono = JetBrains_Mono({
subsets: ['latin'],
variable: '--font-mono',
// Not preloaded: see FONT PRELOADS above.
preload: false,
// next/font's generated fallback for this face is `local(Arial)` — a
// PROPORTIONAL font scaled to stand in for a monospace one. Harmless while
// the face was preloaded; without the preload, code blocks and the eyebrow
// would briefly render in Arial with their columns misaligned. A real
// monospace stack keeps the grid while JetBrains Mono loads.
adjustFontFallback: false,
fallback: ['ui-monospace', 'SFMono-Regular', 'Menlo', 'Consolas', 'monospace'],
});

export const metadata: Metadata = {
Expand Down Expand Up @@ -94,7 +146,7 @@ export default function RootLayout({
return (
<html
lang="en"
className={`${display.variable} ${sans.variable} ${diagram.variable} ${mono.variable}`}
className={`${display.variable} ${sans.variable} ${sansItalic.variable} ${diagram.variable} ${mono.variable}`}
>
<body>
{/*
Expand Down
Loading