From 848e3f90e1fb300bbba24bab46eea86b0caeb921 Mon Sep 17 00:00:00 2001 From: John Jahn Date: Tue, 10 Nov 2026 15:23:58 -0500 Subject: [PATCH] perf(client): the icons the first load needs ship as one chunk instead of 187 Every lucide icon is its own module, and the plugin icon lookup reaches all of them by name from the navigation. The bundler therefore put each icon another chunk also uses into a chunk of its own: 187 files of a few hundred bytes, each one a request on a first visit and an entry in the precache. A `vendor-icons` group collects them. The first load asks for 32 script files instead of 224 (the bytes stay the same, 3.9 MB before compression), and the service worker installs 460 files instead of 652. With 50 ms added to every response the login page reaches DOMContentLoaded in 0.5 s instead of 3.8 s. --- client/iconChunk.js | 18 ++++++++++++++ client/tests/unit/pwa/iconChunk.test.ts | 33 +++++++++++++++++++++++++ client/vite.config.js | 5 +++- 3 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 client/iconChunk.js create mode 100644 client/tests/unit/pwa/iconChunk.test.ts diff --git a/client/iconChunk.js b/client/iconChunk.js new file mode 100644 index 000000000..61ed53cdd --- /dev/null +++ b/client/iconChunk.js @@ -0,0 +1,18 @@ +// The chunk group that holds the lucide icons the app loads at startup (vite.config.js). +// +// Every lucide icon is a module of its own, and the plugin icon lookup +// (src/components/shared/PluginIcon.tsx) reaches all of them by name, from the navigation +// every page renders. Left to itself the bundler puts each icon that some other chunk +// also uses into a chunk of its own, so that neither side holds a copy: 187 files of a few +// hundred bytes each, every one a request of its own on a first visit and an entry of its +// own in the service worker's precache. In one group they are one file, whose hash changes +// only when lucide-react does. +// +// `tags: ['$initial']` for the reason the other groups carry it: without it the group would +// also take icons that today sit behind a lazy route and make them part of the first load. +export const ICON_CHUNK = { + name: 'vendor-icons', + priority: 20, + tags: ['$initial'], + test: /[\\/]node_modules[\\/]lucide-react[\\/]/, +}; diff --git a/client/tests/unit/pwa/iconChunk.test.ts b/client/tests/unit/pwa/iconChunk.test.ts new file mode 100644 index 000000000..0d4a4e6fb --- /dev/null +++ b/client/tests/unit/pwa/iconChunk.test.ts @@ -0,0 +1,33 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { ICON_CHUNK } from '../../../iconChunk.js'; + +describe('ICON_CHUNK', () => { + it('FE-ICON-CHUNK-001: takes every lucide module, on either path separator', () => { + expect(ICON_CHUNK.test.test('/app/node_modules/lucide-react/dist/esm/icons/chevron-down.js')).toBe(true); + expect(ICON_CHUNK.test.test('/app/node_modules/lucide-react/dist/esm/createLucideIcon.js')).toBe(true); + expect(ICON_CHUNK.test.test('C:\\app\\node_modules\\lucide-react\\dist\\esm\\icons\\map.js')).toBe(true); + }); + + it('FE-ICON-CHUNK-002: takes nothing else', () => { + expect(ICON_CHUNK.test.test('/app/node_modules/react/index.js')).toBe(false); + expect(ICON_CHUNK.test.test('/app/node_modules/lucide-react-native/dist/esm/icons/map.js')).toBe(false); + expect(ICON_CHUNK.test.test('/app/client/src/components/shared/PluginIcon.tsx')).toBe(false); + expect(ICON_CHUNK.test.test('/app/client/src/lucide-react/own.ts')).toBe(false); + }); + + it('FE-ICON-CHUNK-003: holds only what the first load already needs', () => { + // Without the tag the group would also collect icons that sit behind a lazy route + // and make them part of the first load (see the note on the groups in vite.config.js). + expect(ICON_CHUNK.tags).toEqual(['$initial']); + expect(ICON_CHUNK.name).toBe('vendor-icons'); + }); + + it('FE-ICON-CHUNK-004: vite.config.js lists it among the chunk groups', () => { + const config = readFileSync(resolve(process.cwd(), 'vite.config.js'), 'utf8'); + const groups = config.slice(config.indexOf('groups: [')); + expect(config).toContain("import { ICON_CHUNK } from './iconChunk.js'"); + expect(groups).toMatch(/groups: \[[\s\S]*?\n {12}ICON_CHUNK,\n/); + }); +}); diff --git a/client/vite.config.js b/client/vite.config.js index af4661cbc..ff771d48e 100644 --- a/client/vite.config.js +++ b/client/vite.config.js @@ -4,6 +4,7 @@ import { VitePWA } from 'vite-plugin-pwa'; import { visualizer } from 'rollup-plugin-visualizer'; import { rtlTextAlias, plyrSpriteAlias } from './rtlTextAlias.js'; import { withoutRuntimeFonts } from './bookFontPrecache.js'; +import { ICON_CHUNK } from './iconChunk.js'; import { readFileSync } from 'node:fs'; // The version this bundle is built as, baked in at build time. The Docker build @@ -70,7 +71,8 @@ export default defineConfig(({ mode }) => ({ // offline-first travel planner a route the user never opened before losing // signal still has to work. The trade is that splitting buys first paint and // not install size: 107 entries / 17,795 KiB before any of it, 463 / - // 23,292 KiB now (measured, not estimated). + // 23,292 KiB after it, and 460 / 59,426 KiB as of 5.1.2 with the icons in + // one chunk (iconChunk.js; 652 entries without it). All measured, not estimated. // // Keep this figure honest. #2228 traced PWA boot failures to the browser // evicting this origin's whole bucket, precached shell included, and this @@ -350,6 +352,7 @@ export default defineConfig(({ mode }) => ({ tags: ['$initial'], test: /[\\/]node_modules[\\/](zustand|dexie|axios|zod|dompurify|isomorphic-dompurify)[\\/]/, }, + ICON_CHUNK, ], }, },