From e7c405679d411c9143ae27492e9a76ee3c41ce14 Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Thu, 22 Oct 2026 11:46:02 +0200 Subject: [PATCH 1/2] download every font once --- .storybook/main.ts | 2 +- assets/README.md | 2 +- assets/css/fonts.css | 24 +++++++++++----------- config/rsbuild/rsbuild.common.ts | 34 ++++++++++++++++++++++++++------ web/index.html | 5 ++--- 5 files changed, 44 insertions(+), 23 deletions(-) diff --git a/.storybook/main.ts b/.storybook/main.ts index c965f8404e3c..d9bb2f932d2f 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -7,7 +7,7 @@ import rsbuildFinal from './rsbuild.config.ts'; const main: StorybookConfig = { stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'], addons: ['@storybook/addon-a11y', '@storybook/addon-docs'], - staticDirs: ['./public', {from: '../assets/css', to: 'css'}, {from: '../assets/fonts/web', to: 'fonts'}], + staticDirs: ['./public', {from: '../assets/css', to: 'css'}, {from: '../assets/fonts/web', to: 'fonts/web'}], core: {}, managerHead: (head) => ` diff --git a/assets/README.md b/assets/README.md index aae609f15a10..d6dd8e078642 100644 --- a/assets/README.md +++ b/assets/README.md @@ -77,7 +77,7 @@ To add or remove a font used in the web app: font-family: ; font-weight: 500; font-style: normal; - src: url('/fonts/.woff2') format('woff2'); + src: url('../fonts/web/.woff2') format('woff2'); } /* Add the other variants too. */ ``` diff --git a/assets/css/fonts.css b/assets/css/fonts.css index 14f1ad684ce2..c19465267b2b 100644 --- a/assets/css/fonts.css +++ b/assets/css/fonts.css @@ -2,80 +2,80 @@ font-family: Expensify Neue; font-weight: 400; font-style: normal; - src: url('/fonts/ExpensifyNeue-Regular.woff2') format('woff2'); + src: url('../fonts/web/ExpensifyNeue-Regular.woff2') format('woff2'); } @font-face { font-family: Expensify Neue; font-weight: 700; font-style: normal; - src: url('/fonts/ExpensifyNeue-Bold.woff2') format('woff2'); + src: url('../fonts/web/ExpensifyNeue-Bold.woff2') format('woff2'); } @font-face { font-family: Expensify Neue; font-weight: 400; font-style: italic; - src: url('/fonts/ExpensifyNeue-Italic.woff2') format('woff2'); + src: url('../fonts/web/ExpensifyNeue-Italic.woff2') format('woff2'); } @font-face { font-family: Expensify Neue; font-weight: 700; font-style: italic; - src: url('/fonts/ExpensifyNeue-BoldItalic.woff2') format('woff2'); + src: url('../fonts/web/ExpensifyNeue-BoldItalic.woff2') format('woff2'); } @font-face { font-family: Expensify Mono; font-weight: 400; font-style: normal; - src: url('/fonts/ExpensifyMono-Regular.woff2') format('woff2'); + src: url('../fonts/web/ExpensifyMono-Regular.woff2') format('woff2'); } @font-face { font-family: Expensify Mono; font-weight: 700; font-style: normal; - src: url('/fonts/ExpensifyMono-Bold.woff2') format('woff2'); + src: url('../fonts/web/ExpensifyMono-Bold.woff2') format('woff2'); } @font-face { font-family: Expensify Mono; font-weight: 400; font-style: italic; - src: url('/fonts/ExpensifyMono-Italic.woff2') format('woff2'); + src: url('../fonts/web/ExpensifyMono-Italic.woff2') format('woff2'); } @font-face { font-family: Expensify Mono; font-weight: 700; font-style: italic; - src: url('/fonts/ExpensifyMono-BoldItalic.woff2') format('woff2'); + src: url('../fonts/web/ExpensifyMono-BoldItalic.woff2') format('woff2'); } @font-face { font-family: Expensify New Kansas; font-weight: 500; font-style: normal; - src: url('/fonts/ExpensifyNewKansas-Medium.woff2') format('woff2'); + src: url('../fonts/web/ExpensifyNewKansas-Medium.woff2') format('woff2'); } @font-face { font-family: Expensify New Kansas; font-weight: 500; font-style: italic; - src: url('/fonts/ExpensifyNewKansas-MediumItalic.woff2') format('woff2'); + src: url('../fonts/web/ExpensifyNewKansas-MediumItalic.woff2') format('woff2'); } @font-face { font-family: Windows Segoe UI Emoji; - src: url('/fonts/seguiemj.woff2') format('woff2'); + src: url('../fonts/web/seguiemj.woff2') format('woff2'); } @font-face { font-family: Custom Emoji Font; - src: url('/fonts/CustomEmojiWebFont.ttf') format('truetype'); + src: url('../fonts/web/CustomEmojiWebFont.ttf') format('truetype'); } * { diff --git a/config/rsbuild/rsbuild.common.ts b/config/rsbuild/rsbuild.common.ts index caf4082088e8..a9655bf5935c 100644 --- a/config/rsbuild/rsbuild.common.ts +++ b/config/rsbuild/rsbuild.common.ts @@ -1,5 +1,5 @@ import type {RsbuildConfig} from '@rsbuild/core'; -import type {DefinePluginOptions, RspackPluginInstance, SwcJsMinimizerRspackPluginOptions} from '@rspack/core'; +import type {Compilation, DefinePluginOptions, RspackPluginInstance, SwcJsMinimizerRspackPluginOptions} from '@rspack/core'; import {GenerateSW} from '@aaroon/workbox-rspack-plugin'; import {pluginSvgr} from '@rsbuild/plugin-svgr'; @@ -57,6 +57,24 @@ const localBranchName = getCurrentBranchName(); */ const CANVASKIT_WASM_FILENAME = `canvaskit-${canvaskitPackageJson.version}.wasm`; +function isCompilation(value: unknown): value is Compilation { + return typeof value === 'object' && value !== null && 'getAssets' in value; +} + +/** + * The URL a font from `assets/fonts/web` is emitted under. `fonts.css` and the charts load each font from its + * content-hashed file, so the inline `@font-face` rules in `web/index.html`, which start the splash screen's fonts + * downloading while the HTML is parsed, must name the same file or the browser downloads the font twice. + */ +function getEmittedFontURL(compilation: unknown, fontFilename: string): string { + const sourceFilename = `assets/fonts/web/${fontFilename}`; + const asset = isCompilation(compilation) ? compilation.getAssets().find(({info}) => info.sourceFilename === sourceFilename) : undefined; + if (!asset) { + throw new Error(`[rsbuild] ${sourceFilename} was not emitted, so web/index.html can't reference it`); + } + return `/${asset.name}`; +} + /** * React Compiler + react-native-worklets loaders. */ @@ -369,7 +387,9 @@ const getCommonConfiguration = async ({file = '.env', platform = 'web', isDevSer // which can only classify frames when it did. __SENTRY_APPLICATION_KEY_STAMPED__: !!sentryWebpackPlugin, }, - entry: {main: './index.js'}, + // fonts.css is bundled rather than copied as a static file, so its font paths resolve to the same + // content-hashed font files the charts import, and the browser downloads each font only once. + entry: {main: ['./assets/css/fonts.css', './index.js']}, }, output: { target: 'web', @@ -410,8 +430,7 @@ const getCommonConfiguration = async ({file = '.env', platform = 'web', isDevSer {from: 'web/robots.txt'}, {from: 'assets/images/expensify-app-icon.svg'}, {from: 'web/manifest.json'}, - {from: 'assets/css', to: 'css'}, - {from: 'assets/fonts/web', to: 'fonts'}, + {from: 'assets/css/pdf.css', to: 'css/pdf.css'}, {from: 'assets/sounds', to: 'sounds'}, {from: 'assets/pdfs', to: 'pdfs'}, {from: 'node_modules/react-pdf/dist/Page/AnnotationLayer.css', to: 'css/AnnotationLayer.css'}, @@ -430,13 +449,16 @@ const getCommonConfiguration = async ({file = '.env', platform = 'web', isDevSer }, html: { template: 'web/index.html', - templateParameters: { + templateParameters: (defaultParameters) => ({ + ...defaultParameters, splashLogo: fs.readFileSync(path.resolve(dirname, `../../assets/images/new-expensify${mapEnvironmentToLogoSuffix(file)}.svg`), 'utf-8'), isWeb: platform === 'web', isProduction: file === '.env.production', isStaging: file === '.env.staging', useThirdPartyScripts: process.env.USE_THIRD_PARTY_SCRIPTS === 'true' || (platform === 'web' && ['.env.production', '.env.staging'].includes(file)), - }, + expensifyNeueRegularFontURL: getEmittedFontURL(defaultParameters.compilation, 'ExpensifyNeue-Regular.woff2'), + expensifyNeueBoldFontURL: getEmittedFontURL(defaultParameters.compilation, 'ExpensifyNeue-Bold.woff2'), + }), }, performance: { // Rsbuild's default exclusion, plus the `.br` twins BrotliCompressionPlugin emits below: listing them would diff --git a/web/index.html b/web/index.html index da7a135748b5..e1b96029fc78 100644 --- a/web/index.html +++ b/web/index.html @@ -18,16 +18,15 @@ font-family: Expensify Neue; font-weight: 400; font-style: normal; - src: url('/fonts/ExpensifyNeue-Regular.woff2') format('woff2'); + src: url('<%= expensifyNeueRegularFontURL %>') format('woff2'); } @font-face { font-family: Expensify Neue; font-weight: 700; font-style: normal; - src: url('/fonts/ExpensifyNeue-Bold.woff2') format('woff2'); + src: url('<%= expensifyNeueBoldFontURL %>') format('woff2'); } - <% if (isStaging) { %> From 9b52dabc3129227b9f9df5f414217134b23ff2a6 Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Thu, 22 Oct 2026 17:57:27 +0200 Subject: [PATCH 2/2] remove inline rules, since they don't preload fonts earlier --- config/rsbuild/rsbuild.common.ts | 27 +++------------------------ web/index.html | 14 -------------- 2 files changed, 3 insertions(+), 38 deletions(-) diff --git a/config/rsbuild/rsbuild.common.ts b/config/rsbuild/rsbuild.common.ts index a9655bf5935c..c8a3c35947f9 100644 --- a/config/rsbuild/rsbuild.common.ts +++ b/config/rsbuild/rsbuild.common.ts @@ -1,5 +1,5 @@ import type {RsbuildConfig} from '@rsbuild/core'; -import type {Compilation, DefinePluginOptions, RspackPluginInstance, SwcJsMinimizerRspackPluginOptions} from '@rspack/core'; +import type {DefinePluginOptions, RspackPluginInstance, SwcJsMinimizerRspackPluginOptions} from '@rspack/core'; import {GenerateSW} from '@aaroon/workbox-rspack-plugin'; import {pluginSvgr} from '@rsbuild/plugin-svgr'; @@ -57,24 +57,6 @@ const localBranchName = getCurrentBranchName(); */ const CANVASKIT_WASM_FILENAME = `canvaskit-${canvaskitPackageJson.version}.wasm`; -function isCompilation(value: unknown): value is Compilation { - return typeof value === 'object' && value !== null && 'getAssets' in value; -} - -/** - * The URL a font from `assets/fonts/web` is emitted under. `fonts.css` and the charts load each font from its - * content-hashed file, so the inline `@font-face` rules in `web/index.html`, which start the splash screen's fonts - * downloading while the HTML is parsed, must name the same file or the browser downloads the font twice. - */ -function getEmittedFontURL(compilation: unknown, fontFilename: string): string { - const sourceFilename = `assets/fonts/web/${fontFilename}`; - const asset = isCompilation(compilation) ? compilation.getAssets().find(({info}) => info.sourceFilename === sourceFilename) : undefined; - if (!asset) { - throw new Error(`[rsbuild] ${sourceFilename} was not emitted, so web/index.html can't reference it`); - } - return `/${asset.name}`; -} - /** * React Compiler + react-native-worklets loaders. */ @@ -449,16 +431,13 @@ const getCommonConfiguration = async ({file = '.env', platform = 'web', isDevSer }, html: { template: 'web/index.html', - templateParameters: (defaultParameters) => ({ - ...defaultParameters, + templateParameters: { splashLogo: fs.readFileSync(path.resolve(dirname, `../../assets/images/new-expensify${mapEnvironmentToLogoSuffix(file)}.svg`), 'utf-8'), isWeb: platform === 'web', isProduction: file === '.env.production', isStaging: file === '.env.staging', useThirdPartyScripts: process.env.USE_THIRD_PARTY_SCRIPTS === 'true' || (platform === 'web' && ['.env.production', '.env.staging'].includes(file)), - expensifyNeueRegularFontURL: getEmittedFontURL(defaultParameters.compilation, 'ExpensifyNeue-Regular.woff2'), - expensifyNeueBoldFontURL: getEmittedFontURL(defaultParameters.compilation, 'ExpensifyNeue-Bold.woff2'), - }), + }, }, performance: { // Rsbuild's default exclusion, plus the `.br` twins BrotliCompressionPlugin emits below: listing them would diff --git a/web/index.html b/web/index.html index e1b96029fc78..4c0e4a7f4eaf 100644 --- a/web/index.html +++ b/web/index.html @@ -13,20 +13,6 @@ - <% if (isStaging) { %>