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.
This commit is contained in:
John Jahn
2026-11-10 15:23:58 -05:00
committed by Maurice
parent e83b6741ad
commit 848e3f90e1
3 changed files with 55 additions and 1 deletions
+18
View File
@@ -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[\\/]/,
};
+33
View File
@@ -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/);
});
});
+4 -1
View File
@@ -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,
],
},
},