From dbf71e2fa7f4e339cc9f2b81939ac38232625704 Mon Sep 17 00:00:00 2001 From: eleith Date: Mon, 2 Nov 2026 15:07:42 -0800 Subject: [PATCH 1/2] perf(web): preload the configured primary font --- .changeset/preload-primary-font.md | 5 ++++ apps/web/e2e/fonts.e2e.ts | 25 +++++++++++++++++++ apps/web/src/lib/server/fonts.test.ts | 22 +++++++++++++++- apps/web/src/lib/server/fonts.ts | 5 ++++ .../routes/(auth)/admin/health/page.test.ts | 1 + apps/web/src/routes/+layout.server.ts | 2 ++ apps/web/src/routes/+layout.svelte | 9 +++++++ 7 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 .changeset/preload-primary-font.md create mode 100644 apps/web/e2e/fonts.e2e.ts diff --git a/.changeset/preload-primary-font.md b/.changeset/preload-primary-font.md new file mode 100644 index 00000000..7949f688 --- /dev/null +++ b/.changeset/preload-primary-font.md @@ -0,0 +1,5 @@ +--- +'@when/web': patch +--- + +Preload the configured font's regular face from the initial HTML so it can download before stylesheet discovery. Respect custom font files and leave other weights and fallback fonts on demand. diff --git a/apps/web/e2e/fonts.e2e.ts b/apps/web/e2e/fonts.e2e.ts new file mode 100644 index 00000000..75610582 --- /dev/null +++ b/apps/web/e2e/fonts.e2e.ts @@ -0,0 +1,25 @@ +import { expect, test } from './support/test.ts'; + +test.use({ javaScriptEnabled: false }); + +test('HTML preloads only the regular configured font and CSS reuses its request', async ({ + page +}) => { + const regularFont = '/assets/fonts/noto-sans/noto-sans-latin-400-normal.woff2'; + const fontRequests: string[] = []; + page.on('request', (request) => { + const path = new URL(request.url()).pathname; + if (path.endsWith('.woff2')) fontRequests.push(path); + }); + + await page.goto('/schedule/chat'); + const preload = page.locator('head link[rel="preload"][as="font"]'); + await expect(preload).toHaveCount(1); + await expect(preload).toHaveAttribute('href', regularFont); + await expect(preload).toHaveAttribute('type', 'font/woff2'); + await expect(preload).toHaveAttribute('crossorigin', 'anonymous'); + await page.evaluate(() => document.fonts.ready.then(() => {})); + + expect(fontRequests.filter((path) => path === regularFont)).toHaveLength(1); + expect(fontRequests.every((path) => path.startsWith('/assets/fonts/noto-sans/'))).toBe(true); +}); diff --git a/apps/web/src/lib/server/fonts.test.ts b/apps/web/src/lib/server/fonts.test.ts index 33c011a1..9de31376 100644 --- a/apps/web/src/lib/server/fonts.test.ts +++ b/apps/web/src/lib/server/fonts.test.ts @@ -1,5 +1,11 @@ import { expect, test } from 'vitest'; -import { FALLBACK_FONT_NAME, bundledFontUrls, fontStack, isBundledFont } from './fonts'; +import { + FALLBACK_FONT_NAME, + bundledFontUrls, + fontStack, + isBundledFont, + primaryFontUrl +} from './fonts'; test('isBundledFont matches families case-insensitively', () => { expect(isBundledFont('Noto Sans')).toBe(true); @@ -20,6 +26,20 @@ test('bundledFontUrls derives per-weight urls from the family name', () => { expect(bundledFontUrls('Unknown')).toEqual([]); }); +test.each([ + ['Noto Sans', '/assets/fonts/noto-sans/noto-sans-latin-400-normal.woff2'], + ['Lato', '/assets/fonts/lato/lato-latin-400-normal.woff2'], + ['Outfit', '/assets/fonts/outfit/outfit-latin-400-normal.woff2'], + ['INTER', '/assets/fonts/inter/inter-latin-400-normal.woff2'], + ['My Font', null] +] as const)('primaryFontUrl preloads only the regular face for %s', (family, expected) => { + expect(primaryFontUrl(family)).toBe(expected); +}); + +test.each(['My Font', 'Noto Sans'])('a custom file takes precedence for %s', (family) => { + expect(primaryFontUrl(family, '/public/custom.woff2')).toBe('/public/custom.woff2'); +}); + test('fontStack backs only custom fonts with the fallback', () => { expect(fontStack('Lato')).toBe('Lato'); expect(fontStack('My Font')).toBe(`My Font, ${FALLBACK_FONT_NAME}`); diff --git a/apps/web/src/lib/server/fonts.ts b/apps/web/src/lib/server/fonts.ts index 958d3906..5da096ed 100644 --- a/apps/web/src/lib/server/fonts.ts +++ b/apps/web/src/lib/server/fonts.ts @@ -20,6 +20,11 @@ export function bundledFontUrls(family: string): string[] { ); } +/** Preload only the configured face; leave other weights and fallbacks on demand. */ +export function primaryFontUrl(family: string, customPath?: string): string | null { + return customPath ?? bundledFontUrls(family)[0] ?? null; +} + export function fontFamilies(family: string): string[] { return isBundledFont(family) ? [family] : [family, FALLBACK_FONT_NAME]; } diff --git a/apps/web/src/routes/(auth)/admin/health/page.test.ts b/apps/web/src/routes/(auth)/admin/health/page.test.ts index 6f6e4fb9..60dc2834 100644 --- a/apps/web/src/routes/(auth)/admin/health/page.test.ts +++ b/apps/web/src/routes/(auth)/admin/health/page.test.ts @@ -12,6 +12,7 @@ const data: PageData = { session: { expires: '2026-05-01T12:00:00Z' }, preferredTimezone: null, appearance: validConfig.user.appearance, + fontPreloadUrl: null, ogImage: 'https://book.example.com/og.png', providers: [ { diff --git a/apps/web/src/routes/+layout.server.ts b/apps/web/src/routes/+layout.server.ts index ddad6844..a3e1155e 100644 --- a/apps/web/src/routes/+layout.server.ts +++ b/apps/web/src/routes/+layout.server.ts @@ -1,4 +1,5 @@ import { getConfig } from '#lib/server/state.js'; +import { primaryFontUrl } from '#lib/server/fonts.js'; import type { LayoutServerLoad } from './$types'; export const load: LayoutServerLoad = ({ cookies }) => { @@ -12,6 +13,7 @@ export const load: LayoutServerLoad = ({ cookies }) => { title: cfg.user.appearance.title, description: cfg.user.appearance.description }, + fontPreloadUrl: primaryFontUrl(cfg.user.appearance.font_name, cfg.user.appearance.font_path), ogImage: `${cfg.url.app}${cfg.user.appearance.opengraph_path}`, preferredTimezone: cookies.get('tz') ?? null }; diff --git a/apps/web/src/routes/+layout.svelte b/apps/web/src/routes/+layout.svelte index fba2f10e..71d906dd 100644 --- a/apps/web/src/routes/+layout.svelte +++ b/apps/web/src/routes/+layout.svelte @@ -27,6 +27,15 @@ + {#if data.fontPreloadUrl} + + {/if} -- 2.54.0 From 4be6755eb8672f6e4314a2a5d7a4d720a0c8e6e4 Mon Sep 17 00:00:00 2001 From: eleith Date: Mon, 2 Nov 2026 15:18:55 -0800 Subject: [PATCH 2/2] fix(web): address primary font preload review findings --- .changeset/preload-primary-font.md | 2 +- apps/web/e2e/fonts.e2e.ts | 19 ++++++++++++++++++- apps/web/src/lib/server/fonts.test.ts | 10 ++++++++++ apps/web/src/lib/server/fonts.ts | 3 ++- 4 files changed, 31 insertions(+), 3 deletions(-) diff --git a/.changeset/preload-primary-font.md b/.changeset/preload-primary-font.md index 7949f688..b9718eb6 100644 --- a/.changeset/preload-primary-font.md +++ b/.changeset/preload-primary-font.md @@ -2,4 +2,4 @@ '@when/web': patch --- -Preload the configured font's regular face from the initial HTML so it can download before stylesheet discovery. Respect custom font files and leave other weights and fallback fonts on demand. +Preload the configured font's regular face from the initial HTML so it can download before stylesheet discovery. Respect custom font files and leave other weights and fallback fonts on demand. Treat inherited object-property names as unknown font families rather than failing page loads. diff --git a/apps/web/e2e/fonts.e2e.ts b/apps/web/e2e/fonts.e2e.ts index 75610582..5d7f27ed 100644 --- a/apps/web/e2e/fonts.e2e.ts +++ b/apps/web/e2e/fonts.e2e.ts @@ -12,13 +12,30 @@ test('HTML preloads only the regular configured font and CSS reuses its request' if (path.endsWith('.woff2')) fontRequests.push(path); }); + const fontResponse = page.waitForResponse( + (response) => new URL(response.url()).pathname === regularFont + ); await page.goto('/schedule/chat'); + expect((await fontResponse).ok()).toBe(true); const preload = page.locator('head link[rel="preload"][as="font"]'); await expect(preload).toHaveCount(1); await expect(preload).toHaveAttribute('href', regularFont); await expect(preload).toHaveAttribute('type', 'font/woff2'); await expect(preload).toHaveAttribute('crossorigin', 'anonymous'); - await page.evaluate(() => document.fonts.ready.then(() => {})); + const fontState = await page.evaluate(async () => { + await document.fonts.ready; + return { + bodyFontFamily: getComputedStyle(document.body).fontFamily, + regularFontLoaded: Array.from(document.fonts).some( + (face) => + face.family.replace(/["']/g, '') === 'Noto Sans' && + face.weight === '400' && + face.status === 'loaded' + ) + }; + }); + expect(fontState.bodyFontFamily).toMatch(/^["']?Noto Sans["']?(?:,|$)/); + expect(fontState.regularFontLoaded).toBe(true); expect(fontRequests.filter((path) => path === regularFont)).toHaveLength(1); expect(fontRequests.every((path) => path.startsWith('/assets/fonts/noto-sans/'))).toBe(true); diff --git a/apps/web/src/lib/server/fonts.test.ts b/apps/web/src/lib/server/fonts.test.ts index 9de31376..e293d0c6 100644 --- a/apps/web/src/lib/server/fonts.test.ts +++ b/apps/web/src/lib/server/fonts.test.ts @@ -40,6 +40,16 @@ test.each(['My Font', 'Noto Sans'])('a custom file takes precedence for %s', (fa expect(primaryFontUrl(family, '/public/custom.woff2')).toBe('/public/custom.woff2'); }); +test.each(['constructor', '__proto__'])( + 'inherited object key %s is not a bundled family', + (family) => { + expect(isBundledFont(family)).toBe(false); + expect(bundledFontUrls(family)).toEqual([]); + expect(primaryFontUrl(family)).toBeNull(); + expect(fontStack(family)).toBe(`${family}, ${FALLBACK_FONT_NAME}`); + } +); + test('fontStack backs only custom fonts with the fallback', () => { expect(fontStack('Lato')).toBe('Lato'); expect(fontStack('My Font')).toBe(`My Font, ${FALLBACK_FONT_NAME}`); diff --git a/apps/web/src/lib/server/fonts.ts b/apps/web/src/lib/server/fonts.ts index 5da096ed..d663cd13 100644 --- a/apps/web/src/lib/server/fonts.ts +++ b/apps/web/src/lib/server/fonts.ts @@ -9,10 +9,11 @@ const BUNDLED_FONT_WEIGHTS: Record = { }; export function isBundledFont(family: string): boolean { - return family.toLowerCase() in BUNDLED_FONT_WEIGHTS; + return Object.hasOwn(BUNDLED_FONT_WEIGHTS, family.toLowerCase()); } export function bundledFontUrls(family: string): string[] { + if (!isBundledFont(family)) return []; const key = family.toLowerCase(); const slug = key.replaceAll(' ', '-'); return (BUNDLED_FONT_WEIGHTS[key] ?? []).map( -- 2.54.0