From 06599addb04940284b89706a000905898d427f2e Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Mon, 21 Sep 2026 14:07:37 +0200 Subject: [PATCH 1/7] add brotli compression to the build --- .github/workflows/buildAdHoc.yml | 1 + .github/workflows/deploy.yml | 11 ++++++++++- config/rsbuild/rsbuild.common.ts | 23 +++++++++++++++++++++++ package-lock.json | 32 ++++++++++++++++++++++++++++++++ package.json | 1 + 5 files changed, 67 insertions(+), 1 deletion(-) diff --git a/.github/workflows/buildAdHoc.yml b/.github/workflows/buildAdHoc.yml index 694b9927898a..3633c87fb9cb 100644 --- a/.github/workflows/buildAdHoc.yml +++ b/.github/workflows/buildAdHoc.yml @@ -136,6 +136,7 @@ jobs: BUCKET="s3://ad-hoc-expensify-cash/web/${{ inputs.APP_PR_NUMBER }}" aws s3 sync dist "$BUCKET" --delete --acl public-read \ + --exclude "*.br" \ --exclude "index.html" \ --exclude "service-worker.js" \ --exclude "workbox-*.js" \ diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index a1e6219d0b9e..b3cf970e11fe 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -741,7 +741,16 @@ jobs: - name: Deploy to S3 run: | - aws s3 cp --recursive --acl public-read "$GITHUB_WORKSPACE"/dist ${{ env.S3_BUCKET }}/ + aws s3 cp --recursive --acl public-read --exclude "*.br" "$GITHUB_WORKSPACE"/dist ${{ env.S3_BUCKET }}/ + + for PAIR in js:text/javascript css:text/css html:text/html svg:image/svg+xml wasm:application/wasm ttf:font/ttf; do + EXT="${PAIR%%:*}" + CONTENT_TYPE="${PAIR#*:}" + aws s3 cp --recursive --acl public-read --exclude "*" --include "*.${EXT}.br" \ + --content-encoding br --content-type "$CONTENT_TYPE" \ + "$GITHUB_WORKSPACE"/dist ${{ env.S3_BUCKET }}/ + done + aws s3 cp --acl public-read --content-type 'application/json' --metadata-directive REPLACE ${{ env.S3_BUCKET }}/.well-known/apple-app-site-association ${{ env.S3_BUCKET }}/.well-known/apple-app-site-association aws s3 cp --acl public-read --content-type 'application/json' --metadata-directive REPLACE ${{ env.S3_BUCKET }}/.well-known/apple-app-site-association ${{ env.S3_BUCKET }}/apple-app-site-association env: diff --git a/config/rsbuild/rsbuild.common.ts b/config/rsbuild/rsbuild.common.ts index 5fa24d76aa5c..08cdf6eec4a1 100644 --- a/config/rsbuild/rsbuild.common.ts +++ b/config/rsbuild/rsbuild.common.ts @@ -1,16 +1,19 @@ import type {RsbuildConfig} from '@rsbuild/core'; import type {DefinePluginOptions, RspackPluginInstance, SwcJsMinimizerRspackPluginOptions} from '@rspack/core'; +import type {BrotliOptions} from 'zlib'; import {GenerateSW} from '@aaroon/workbox-rspack-plugin'; import {pluginSvgr} from '@rsbuild/plugin-svgr'; import {RsdoctorRspackPlugin} from '@rsdoctor/rspack-plugin'; import {rspack} from '@rspack/core'; import {execSync} from 'child_process'; +import CompressionPlugin from 'compression-webpack-plugin'; import dotenv from 'dotenv'; import fs from 'fs'; import {createRequire} from 'module'; import path from 'path'; import {fileURLToPath} from 'url'; +import zlib from 'zlib'; import type Environment from './types.ts'; @@ -419,6 +422,9 @@ const getCommonConfiguration = async ({file = '.env', platform = 'web', isDevSer }, }, performance: { + // Rsbuild's default exclusion, plus the `.br` twins CompressionPlugin emits below: listing them would + // double the report with a meaningless "gzipped size" of already-Brotli-compressed bytes. + printFileSize: {exclude: (asset) => /\.(?:map|LICENSE\.txt|d\.(?:ts|mts|cts)|br)$/.test(asset.name)}, // We have to load the whole lottie player to get the player to work in offline mode // heic-to library is used sparsely so we load it as a separate chunk to reduce initial bundle size // ExpensifyIcons/illustrations chunks are loaded eagerly for offline support @@ -502,6 +508,10 @@ const getCommonConfiguration = async ({file = '.env', platform = 'web', isDevSer // all critical for offline boot, so we precache the lot. Everything in the // App build is content-hashed, so growth here only costs first-install bytes. maximumFileSizeToCacheInBytes: 10 * 1024 * 1024, + // Workbox's defaults, plus the `.br` twins CompressionPlugin emits: the service + // worker requests the original URLs and the CDN transparently serves the Brotli copy, + // so adding the twins to the precache as well would download every chunk twice. + exclude: [/\.map$/, /^manifest.*\.js$/, /\.br$/], // Single-page app: any unmatched navigation should serve the cached app shell. navigateFallback: '/index.html', // Don't fall back for asset-like or .well-known requests. @@ -591,6 +601,19 @@ const getCommonConfiguration = async ({file = '.env', platform = 'web', isDevSer : []), // This allows us to interactively inspect JS bundle contents, loader/plugin timings, and duplicate packages ...(process.env.ANALYZE_BUNDLE === 'true' ? [new RsdoctorRspackPlugin()] : []), + // Writes a Brotli 11 twin (`foo.js` -> `foo.js.br`) beside every deployable text/bytecode asset, so the CDN + // can serve it instead of gzipping on the fly: 25-30% fewer bytes over the wire. + ...(isDevelopment + ? [] + : [ + new CompressionPlugin({ + algorithm: 'brotliCompress', + test: /\.(?:js|css|html|svg|wasm|ttf)$/, + compressionOptions: {params: {[zlib.constants.BROTLI_PARAM_QUALITY]: zlib.constants.BROTLI_MAX_QUALITY}}, + threshold: 0, + minRatio: Infinity, + }), + ]), ); return afterShared; diff --git a/package-lock.json b/package-lock.json index 50d846335eff..0d5c05ff5718 100644 --- a/package-lock.json +++ b/package-lock.json @@ -239,6 +239,7 @@ "babel-plugin-transform-remove-console": "^6.9.4", "babel-preset-expo": "^57.0.3", "bun": "^1.3.14", + "compression-webpack-plugin": "^12.0.0", "concurrently": "^9.2.4", "cspell": "9.4.0", "csv-parse": "^5.5.5", @@ -23861,6 +23862,37 @@ "node": ">= 0.8.0" } }, + "node_modules/compression-webpack-plugin": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/compression-webpack-plugin/-/compression-webpack-plugin-12.0.0.tgz", + "integrity": "sha512-LR4mS19Jqq41XfA3xVMLrtzVNzqJbUHdzPeLRfQoLiAS9s87f0021fDuU89xxVQFcB6d20ufBkv4j1rQ4OowHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "schema-utils": "^4.2.0", + "serialize-javascript": "^7.0.3" + }, + "engines": { + "node": ">= 20.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + } + }, + "node_modules/compression-webpack-plugin/node_modules/serialize-javascript": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.1.1.tgz", + "integrity": "sha512-k3CMsaIvvdSwm8oLB4MXSl0wH2/cwlH7xGcnRd2DaeRmBkbzYmyT8j0tsX60DwD1eRwHTpNpH8ljKu9oUT1MeQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/compression/node_modules/debug": { "version": "2.6.9", "license": "MIT", diff --git a/package.json b/package.json index a61b1c900334..fa2db3c3d719 100644 --- a/package.json +++ b/package.json @@ -315,6 +315,7 @@ "babel-plugin-transform-remove-console": "^6.9.4", "babel-preset-expo": "^57.0.3", "bun": "^1.3.14", + "compression-webpack-plugin": "^12.0.0", "concurrently": "^9.2.4", "cspell": "9.4.0", "csv-parse": "^5.5.5", From 28fe2bc5737b2c5710d3f60e588ffb430ed737d9 Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Mon, 21 Sep 2026 14:22:38 +0200 Subject: [PATCH 2/7] fix spell --- config/rsbuild/rsbuild.common.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/rsbuild/rsbuild.common.ts b/config/rsbuild/rsbuild.common.ts index 08cdf6eec4a1..10888cb098ae 100644 --- a/config/rsbuild/rsbuild.common.ts +++ b/config/rsbuild/rsbuild.common.ts @@ -602,7 +602,7 @@ const getCommonConfiguration = async ({file = '.env', platform = 'web', isDevSer // This allows us to interactively inspect JS bundle contents, loader/plugin timings, and duplicate packages ...(process.env.ANALYZE_BUNDLE === 'true' ? [new RsdoctorRspackPlugin()] : []), // Writes a Brotli 11 twin (`foo.js` -> `foo.js.br`) beside every deployable text/bytecode asset, so the CDN - // can serve it instead of gzipping on the fly: 25-30% fewer bytes over the wire. + // can serve it instead of compressing with gzip on the fly: 25-30% fewer bytes over the wire. ...(isDevelopment ? [] : [ From 3cea6e50fa22c91783460cde76aa2333b7eb20ff Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Mon, 21 Sep 2026 16:02:04 +0200 Subject: [PATCH 3/7] add comment --- config/rsbuild/rsbuild.common.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/config/rsbuild/rsbuild.common.ts b/config/rsbuild/rsbuild.common.ts index 10888cb098ae..c8dca7cc9f77 100644 --- a/config/rsbuild/rsbuild.common.ts +++ b/config/rsbuild/rsbuild.common.ts @@ -610,6 +610,10 @@ const getCommonConfiguration = async ({file = '.env', platform = 'web', isDevSer algorithm: 'brotliCompress', test: /\.(?:js|css|html|svg|wasm|ttf)$/, compressionOptions: {params: {[zlib.constants.BROTLI_PARAM_QUALITY]: zlib.constants.BROTLI_MAX_QUALITY}}, + // Every matching file must get a twin: the CDN rewrite appends `.br` blindly, so a skipped file is a 404. + // `threshold` skips files smaller than N bytes (0 = none). `minRatio` skips a twin when + // `compressed / original` is greater than it (default 0.8 = keep only twins at least 20% smaller); + // nothing is greater than Infinity, so even files Brotli cannot shrink keep theirs. threshold: 0, minRatio: Infinity, }), From 46541f5f40b2b7b6e5c7932ca8ddfa84e40e5079 Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Tue, 22 Sep 2026 10:46:07 +0200 Subject: [PATCH 4/7] add explicit filename to Brotli compression --- config/rsbuild/rsbuild.common.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/config/rsbuild/rsbuild.common.ts b/config/rsbuild/rsbuild.common.ts index c8dca7cc9f77..0c20bc084ed0 100644 --- a/config/rsbuild/rsbuild.common.ts +++ b/config/rsbuild/rsbuild.common.ts @@ -608,6 +608,7 @@ const getCommonConfiguration = async ({file = '.env', platform = 'web', isDevSer : [ new CompressionPlugin({ algorithm: 'brotliCompress', + filename: '[path][base].br', test: /\.(?:js|css|html|svg|wasm|ttf)$/, compressionOptions: {params: {[zlib.constants.BROTLI_PARAM_QUALITY]: zlib.constants.BROTLI_MAX_QUALITY}}, // Every matching file must get a twin: the CDN rewrite appends `.br` blindly, so a skipped file is a 404. From cbee5d115e8356588615e78b04d7c669b3d63279 Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Wed, 23 Sep 2026 13:57:56 +0200 Subject: [PATCH 5/7] add our own compression plugin --- config/rsbuild/BrotliCompressionPlugin.ts | 54 +++++++++++++++++++++++ config/rsbuild/rsbuild.common.ts | 25 +++-------- package-lock.json | 32 -------------- package.json | 1 - 4 files changed, 59 insertions(+), 53 deletions(-) create mode 100644 config/rsbuild/BrotliCompressionPlugin.ts diff --git a/config/rsbuild/BrotliCompressionPlugin.ts b/config/rsbuild/BrotliCompressionPlugin.ts new file mode 100644 index 000000000000..c95384e3c3b0 --- /dev/null +++ b/config/rsbuild/BrotliCompressionPlugin.ts @@ -0,0 +1,54 @@ +import type {Compiler} from '@rspack/core'; + +import {promisify} from 'util'; +import zlib from 'zlib'; + +const PLUGIN_NAME = 'BrotliCompressionPlugin'; + +const brotliCompress = promisify(zlib.brotliCompress); + +type Options = { + /** Every emitted asset whose name matches gets a `.br` twin. */ + test: RegExp; +}; + +/** + * Rspack plugin that writes a Brotli 11 twin (`foo.js` -> `foo.js.br`) beside every emitted asset matching `test`. + * Every match gets one, however small or incompressible: the CDN rewrite appends `.br` blindly, so a missing twin is a 404. + */ +class BrotliCompressionPlugin { + private readonly options: Options; + + constructor(options: Options) { + this.options = options; + } + + apply(compiler: Compiler): void { + const {Compilation, sources} = compiler.rspack; + + // `thisCompilation` skips child compilations, whose assets end up in the parent's anyway. + compiler.hooks.thisCompilation.tap(PLUGIN_NAME, (compilation) => { + compilation.hooks.processAssets.tapPromise( + // OPTIMIZE_TRANSFER runs after minification, content hashing, HTML and the service worker are emitted, + // so the twins are made from the final bytes that will be deployed. + {name: PLUGIN_NAME, stage: Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE_TRANSFER}, + async () => { + const assets = compilation.getAssets().filter(({name, info}) => !info.compressed && this.options.test.test(name)); + + // zlib runs each compression on the libuv thread pool, so these proceed in parallel. + await Promise.all( + assets.map(async ({name, source, info}) => { + const compressed = await brotliCompress(source.buffer(), {params: {[zlib.constants.BROTLI_PARAM_QUALITY]: zlib.constants.BROTLI_MAX_QUALITY}}); + + // `compressed` is the webpack convention that tells other plugins (and this one on a rebuild) not to + // compress the twin again. A twin of a content-hashed file is as immutable as its original. + compilation.emitAsset(`${name}.br`, new sources.RawSource(compressed), {compressed: true, immutable: info.immutable}); + }), + ); + }, + ); + }); + } +} + +export default BrotliCompressionPlugin; diff --git a/config/rsbuild/rsbuild.common.ts b/config/rsbuild/rsbuild.common.ts index c8dca7cc9f77..765d17d494f9 100644 --- a/config/rsbuild/rsbuild.common.ts +++ b/config/rsbuild/rsbuild.common.ts @@ -1,19 +1,16 @@ import type {RsbuildConfig} from '@rsbuild/core'; import type {DefinePluginOptions, RspackPluginInstance, SwcJsMinimizerRspackPluginOptions} from '@rspack/core'; -import type {BrotliOptions} from 'zlib'; import {GenerateSW} from '@aaroon/workbox-rspack-plugin'; import {pluginSvgr} from '@rsbuild/plugin-svgr'; import {RsdoctorRspackPlugin} from '@rsdoctor/rspack-plugin'; import {rspack} from '@rspack/core'; import {execSync} from 'child_process'; -import CompressionPlugin from 'compression-webpack-plugin'; import dotenv from 'dotenv'; import fs from 'fs'; import {createRequire} from 'module'; import path from 'path'; import {fileURLToPath} from 'url'; -import zlib from 'zlib'; import type Environment from './types.ts'; @@ -24,6 +21,8 @@ import SENTRY_APPLICATION_KEY from '../../src/libs/telemetry/sentryApplicationKe import getAppVersion from '../../src/libs/VersionUtils.ts'; // eslint-disable-line @dword-design/import-alias/prefer-alias import oxcReactCompilerConfig from '../babel/oxcReactCompilerConfig.js'; // @ts-expect-error -- Can't use .ts extensions without allowImportingTsExtensions in tsconfig +import BrotliCompressionPlugin from './BrotliCompressionPlugin.ts'; +// @ts-expect-error -- Can't use .ts extensions without allowImportingTsExtensions in tsconfig import CustomVersionFilePlugin from './CustomVersionFilePlugin.ts'; // @ts-expect-error -- Can't use .ts extensions without allowImportingTsExtensions in tsconfig import ModuleInitTimingPlugin from './ModuleInitTimingPlugin.ts'; @@ -422,7 +421,7 @@ const getCommonConfiguration = async ({file = '.env', platform = 'web', isDevSer }, }, performance: { - // Rsbuild's default exclusion, plus the `.br` twins CompressionPlugin emits below: listing them would + // Rsbuild's default exclusion, plus the `.br` twins BrotliCompressionPlugin emits below: listing them would // double the report with a meaningless "gzipped size" of already-Brotli-compressed bytes. printFileSize: {exclude: (asset) => /\.(?:map|LICENSE\.txt|d\.(?:ts|mts|cts)|br)$/.test(asset.name)}, // We have to load the whole lottie player to get the player to work in offline mode @@ -508,7 +507,7 @@ const getCommonConfiguration = async ({file = '.env', platform = 'web', isDevSer // all critical for offline boot, so we precache the lot. Everything in the // App build is content-hashed, so growth here only costs first-install bytes. maximumFileSizeToCacheInBytes: 10 * 1024 * 1024, - // Workbox's defaults, plus the `.br` twins CompressionPlugin emits: the service + // Workbox's defaults, plus the `.br` twins BrotliCompressionPlugin emits: the service // worker requests the original URLs and the CDN transparently serves the Brotli copy, // so adding the twins to the precache as well would download every chunk twice. exclude: [/\.map$/, /^manifest.*\.js$/, /\.br$/], @@ -603,21 +602,7 @@ const getCommonConfiguration = async ({file = '.env', platform = 'web', isDevSer ...(process.env.ANALYZE_BUNDLE === 'true' ? [new RsdoctorRspackPlugin()] : []), // Writes a Brotli 11 twin (`foo.js` -> `foo.js.br`) beside every deployable text/bytecode asset, so the CDN // can serve it instead of compressing with gzip on the fly: 25-30% fewer bytes over the wire. - ...(isDevelopment - ? [] - : [ - new CompressionPlugin({ - algorithm: 'brotliCompress', - test: /\.(?:js|css|html|svg|wasm|ttf)$/, - compressionOptions: {params: {[zlib.constants.BROTLI_PARAM_QUALITY]: zlib.constants.BROTLI_MAX_QUALITY}}, - // Every matching file must get a twin: the CDN rewrite appends `.br` blindly, so a skipped file is a 404. - // `threshold` skips files smaller than N bytes (0 = none). `minRatio` skips a twin when - // `compressed / original` is greater than it (default 0.8 = keep only twins at least 20% smaller); - // nothing is greater than Infinity, so even files Brotli cannot shrink keep theirs. - threshold: 0, - minRatio: Infinity, - }), - ]), + ...(isDevelopment ? [] : [new BrotliCompressionPlugin({test: /\.(?:js|css|html|svg|wasm|ttf)$/})]), ); return afterShared; diff --git a/package-lock.json b/package-lock.json index 0d5c05ff5718..50d846335eff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -239,7 +239,6 @@ "babel-plugin-transform-remove-console": "^6.9.4", "babel-preset-expo": "^57.0.3", "bun": "^1.3.14", - "compression-webpack-plugin": "^12.0.0", "concurrently": "^9.2.4", "cspell": "9.4.0", "csv-parse": "^5.5.5", @@ -23862,37 +23861,6 @@ "node": ">= 0.8.0" } }, - "node_modules/compression-webpack-plugin": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/compression-webpack-plugin/-/compression-webpack-plugin-12.0.0.tgz", - "integrity": "sha512-LR4mS19Jqq41XfA3xVMLrtzVNzqJbUHdzPeLRfQoLiAS9s87f0021fDuU89xxVQFcB6d20ufBkv4j1rQ4OowHw==", - "dev": true, - "license": "MIT", - "dependencies": { - "schema-utils": "^4.2.0", - "serialize-javascript": "^7.0.3" - }, - "engines": { - "node": ">= 20.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^5.1.0" - } - }, - "node_modules/compression-webpack-plugin/node_modules/serialize-javascript": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.1.1.tgz", - "integrity": "sha512-k3CMsaIvvdSwm8oLB4MXSl0wH2/cwlH7xGcnRd2DaeRmBkbzYmyT8j0tsX60DwD1eRwHTpNpH8ljKu9oUT1MeQ==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=20.0.0" - } - }, "node_modules/compression/node_modules/debug": { "version": "2.6.9", "license": "MIT", diff --git a/package.json b/package.json index fa2db3c3d719..a61b1c900334 100644 --- a/package.json +++ b/package.json @@ -315,7 +315,6 @@ "babel-plugin-transform-remove-console": "^6.9.4", "babel-preset-expo": "^57.0.3", "bun": "^1.3.14", - "compression-webpack-plugin": "^12.0.0", "concurrently": "^9.2.4", "cspell": "9.4.0", "csv-parse": "^5.5.5", From 8cd37e9de0c85ec5dbbf73ca43ec13a7df2f55a1 Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Thu, 24 Sep 2026 11:55:57 +0200 Subject: [PATCH 6/7] exclude ad-hoc when generating .br --- config/rsbuild/rsbuild.common.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/config/rsbuild/rsbuild.common.ts b/config/rsbuild/rsbuild.common.ts index 765d17d494f9..cf275d1aabdf 100644 --- a/config/rsbuild/rsbuild.common.ts +++ b/config/rsbuild/rsbuild.common.ts @@ -329,6 +329,7 @@ const getSharedConfiguration = ({file = '.env', isDevServer = false}: Environmen */ const getCommonConfiguration = async ({file = '.env', platform = 'web', isDevServer = false}: Environment): Promise => { const isDevelopment = file === '.env' || file === '.env.development'; + const shouldCompressWithBrotli = !isDevelopment && file !== '.env.adhoc'; const shared = getSharedConfiguration({file, platform, isDevServer}); const sharedRspackTool = shared.tools?.rspack; const sentryWebpackPlugin = isDevelopment ? undefined : (await import('@sentry/webpack-plugin')).sentryWebpackPlugin; @@ -602,7 +603,7 @@ const getCommonConfiguration = async ({file = '.env', platform = 'web', isDevSer ...(process.env.ANALYZE_BUNDLE === 'true' ? [new RsdoctorRspackPlugin()] : []), // Writes a Brotli 11 twin (`foo.js` -> `foo.js.br`) beside every deployable text/bytecode asset, so the CDN // can serve it instead of compressing with gzip on the fly: 25-30% fewer bytes over the wire. - ...(isDevelopment ? [] : [new BrotliCompressionPlugin({test: /\.(?:js|css|html|svg|wasm|ttf)$/})]), + ...(shouldCompressWithBrotli ? [new BrotliCompressionPlugin({test: /\.(?:js|css|html|svg|wasm|ttf)$/})] : []), ); return afterShared; From f5846dc5b883442af646193855d0fcd731a10367 Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Thu, 24 Sep 2026 15:05:23 +0200 Subject: [PATCH 7/7] fix comment explanation --- config/rsbuild/BrotliCompressionPlugin.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/config/rsbuild/BrotliCompressionPlugin.ts b/config/rsbuild/BrotliCompressionPlugin.ts index c95384e3c3b0..0d3cbf69fbdc 100644 --- a/config/rsbuild/BrotliCompressionPlugin.ts +++ b/config/rsbuild/BrotliCompressionPlugin.ts @@ -14,7 +14,8 @@ type Options = { /** * Rspack plugin that writes a Brotli 11 twin (`foo.js` -> `foo.js.br`) beside every emitted asset matching `test`. - * Every match gets one, however small or incompressible: the CDN rewrite appends `.br` blindly, so a missing twin is a 404. + * Every match gets one, however small or incompressible: the CDN rewrite appends `.br` blindly, and a missing twin doesn't even 404 — the SPA fallback answers 200 + * with the app shell, so the browser either fails to decode that HTML as Brotli or refuses to run it as a script. */ class BrotliCompressionPlugin { private readonly options: Options;