perf(web): preload the configured primary font #120
No files matched your search
@@ -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. Treat inherited object-property names as unknown font families rather than failing page loads.
|
||||
@@ -0,0 +1,42 @@
|
||||
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);
|
||||
});
|
||||
|
||||
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');
|
||||
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);
|
||||
});
|
||||
@@ -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,30 @@ 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.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}`);
|
||||
|
||||
@@ -9,10 +9,11 @@ const BUNDLED_FONT_WEIGHTS: Record<string, number[]> = {
|
||||
};
|
||||
|
||||
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(
|
||||
@@ -20,6 +21,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];
|
||||
}
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -27,6 +27,15 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
{#if data.fontPreloadUrl}
|
||||
<link
|
||||
rel="preload"
|
||||
href={data.fontPreloadUrl}
|
||||
as="font"
|
||||
type="font/woff2"
|
||||
crossorigin="anonymous"
|
||||
/>
|
||||
{/if}
|
||||
<link rel="icon" href={favicon} />
|
||||
<meta name="description" content={data.appearance.description} />
|
||||
<meta property="og:type" content="website" />
|
||||
|
||||
Reference in new issue
Block a user