Reduce main bundle size by ~44% gzipped (732 KB → 412 KB) (#4229)

## Summary

Cuts the main JS chunk from **2,891 KB (732 KB gzip)** to **1,679 KB
(412 KB gzip)** by fixing two bundling issues and removing/replacing
heavy dependencies. Measured with a per-module `renderedLength` analysis
of the rolldown output (its prod sourcemaps are malformed, so
sourcemap-based tools misattribute sizes).

| Chunk | Before | After |
|---|---|---|
| `index-*.js` (min) | 2,891 KB | 1,679 KB |
| `index-*.js` (gzip) | 732 KB | **412 KB** |

## Changes

- **Sim worker moved out of the main bundle (~512 KB).** The
`?worker&inline` payload is now reached through a dynamic `import()`, so
it lands in its own lazy chunk fetched when a game starts. The worker
itself still uses Vite's inline Blob mechanism (with its `data:` URL
fallback) — runtime instantiation is byte-for-byte unchanged.
- **Replaced `lit-markdown` with `marked` + the already-bundled
DOMPurify (~380 KB).** lit-markdown transitively pulled sanitize-html,
htmlparser2, postcss, and two copies of entities into the client just to
render news markdown. New `src/client/Markdown.ts` matches its
image-stripping default.
- **Dropped `colorjs.io` (~114 KB).** It was only used for ΔE2000
distance in `ColorAllocator`; colord's lab plugin (already imported
there) provides the same CIEDE2000 via `.delta()`. Only relative
magnitudes are compared, so allocation behavior is unchanged.
- **`msdf-atlas.json` (~319 KB) fetched at runtime** like the atlas PNG,
preloaded in parallel with worker init in `ClientGameRunner` so
game-load latency is unaffected.
- **Tailwind CSS no longer shipped twice (~158 KB).** `o-modal` imported
`styles.css?inline`, duplicating the emitted stylesheet as a JS string.
It now adopts a constructed stylesheet built from the document's own CSS
(HTTP-cache hit in prod, `<style>` tags + HMR re-sync in dev) via
`SharedStyles.ts`.
- **Debug GUI lazy-loaded.** lil-gui + `gl/debug/*` now load on first
toggle (46 KB lazy chunk) instead of shipping in the main bundle.

Also looked at the `import * as d3` in RadialMenu (~84 KB) but left it:
rolldown tree-shakes the metapackage well and all but ~2 KB is the
genuine dependency closure of the selection/transition/shape/color APIs
in use.

## Test plan

- [x] `tsc --noEmit` clean
- [x] ESLint clean
- [x] Full test suite passes (1,374 + 65 tests)
- [x] `npm run build-prod` succeeds; worker/debug chunks present in
`asset-manifest.json` for the R2 upload
- [ ] Manual smoke test in dev: start a game (worker dev path), open a
modal (shared stylesheet), open news modal (markdown rendering)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Evan
2026-06-11 20:07:16 -07:00
committed by GitHub
co-authored by Claude Fable 5
parent 2789db8b96
commit 94f2293149
12 changed files with 174 additions and 226 deletions
+18 -4
View File
@@ -71,9 +71,9 @@ import { createRenderer, GameRenderer } from "./hud/GameRenderer";
import {
applyDarkModeOverride,
applyGraphicsOverrides,
createDebugGui,
createRenderSettings,
deepAssign,
preloadAtlasData,
GameView as WebGLGameView,
} from "./render/gl";
import { ALL_UNIT_TYPES, UnitState } from "./render/types";
@@ -451,8 +451,12 @@ async function createClientGame(
mapLoader,
);
}
// Kick off the font-atlas fetch so it overlaps with worker init; the
// render passes need it parsed before createWebGLView runs.
const atlasDataLoad = preloadAtlasData();
const worker = new WorkerClient(lobbyConfig.gameStartInfo, clientID);
await worker.initialize();
await atlasDataLoad;
const gameView = new GameView(
worker,
config,
@@ -519,11 +523,21 @@ async function createClientGame(
{ signal: graphicsListenerAbort.signal },
);
let debugGui: ReturnType<typeof createDebugGui> | null = null;
// Loaded on demand so lil-gui and the debug GUI stay out of the main bundle.
let debugGui: { open(): void; destroy(): void } | null = null;
let debugGuiLoading = false;
eventBus.on(ToggleRenderDebugGuiEvent, () => {
if (debugGui === null) {
debugGui = createDebugGui(view.getSettings());
debugGui.open();
if (debugGuiLoading) return;
debugGuiLoading = true;
import("./render/gl/debug/index")
.then(({ createDebugGui }) => {
debugGui = createDebugGui(view.getSettings());
debugGui.open();
})
.finally(() => {
debugGuiLoading = false;
});
} else {
debugGui.destroy();
debugGui = null;
+21
View File
@@ -0,0 +1,21 @@
import DOMPurify from "dompurify";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import { marked } from "marked";
/**
* Render markdown to a sanitized lit template value. Images are stripped
* unless `includeImages` is set (matching the lit-markdown default this
* replaces — lit-markdown pulled in sanitize-html (~325 KB min); DOMPurify is
* already in the bundle).
*/
export function renderMarkdown(
rawMarkdown: string,
options?: { includeImages?: boolean },
) {
const rawHTML = marked.parse(rawMarkdown, { async: false });
const cleanHTML = DOMPurify.sanitize(
rawHTML,
options?.includeImages ? {} : { FORBID_TAGS: ["img"] },
);
return unsafeHTML(cleanHTML);
}
+2 -5
View File
@@ -1,11 +1,11 @@
import { html, LitElement } from "lit";
import { resolveMarkdown } from "lit-markdown";
import { customElement, property, query } from "lit/decorators.js";
import version from "resources/version.txt?raw";
import { translateText } from "../client/Utils";
import { assetUrl } from "../core/AssetUrls";
import { BaseModal } from "./components/BaseModal";
import { modalHeader } from "./components/ui/ModalHeader";
import { renderMarkdown } from "./Markdown";
import { normalizeNewsMarkdown } from "./NewsMarkdown";
@customElement("news-modal")
@@ -36,10 +36,7 @@ export class NewsModal extends BaseModal {
[&_li]:text-gray-300 [&_li]:leading-relaxed
[&_p]:text-gray-300 [&_p]:mb-3 [&_strong]:text-white [&_strong]:font-bold"
>
${resolveMarkdown(this.markdown, {
includeImages: true,
includeCodeBlockClassNames: true,
})}
${renderMarkdown(this.markdown, { includeImages: true })}
</div>
`;
}
+2 -2
View File
@@ -1,8 +1,8 @@
import { LitElement, html, nothing } from "lit";
import { resolveMarkdown } from "lit-markdown";
import { customElement, state } from "lit/decorators.js";
import type { NewsItem } from "../../core/ApiSchemas";
import { getNews } from "../Api";
import { renderMarkdown } from "../Markdown";
import { translateText } from "../Utils";
export type { NewsItem };
@@ -140,7 +140,7 @@ export class NewsBox extends LitElement {
>`}
<span
class="text-xs text-white/50 block [&_a]:text-blue-300 [&_a:hover]:text-blue-200"
>${resolveMarkdown(
>${renderMarkdown(
item.descriptionTranslationKey
? translateText(item.descriptionTranslationKey)
: (item.description ?? ""),
@@ -1,12 +1,12 @@
import { LitElement, html, unsafeCSS } from "lit";
import { LitElement, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import tailwindStyles from "../../styles.css?inline";
import { documentStylesSheet } from "./SharedStyles";
export type OModalTab = { key: string; label: string };
@customElement("o-modal")
export class OModal extends LitElement {
static styles = [unsafeCSS(tailwindStyles)];
static styles = [documentStylesSheet()];
@state() public isModalOpen = false;
@@ -0,0 +1,53 @@
/**
* Shared constructed stylesheet mirroring the document's global CSS, for
* shadow-DOM components that want the page's Tailwind styles. Importing
* styles.css?inline instead would ship the full Tailwind CSS a second time
* (~160 KB) inside the JS bundle.
*
* In production the page stylesheet <link> is fetched (same URL the browser
* already loaded, so it resolves from HTTP cache); in dev Vite injects
* <style> tags whose text is read directly and re-read on HMR updates.
*/
let sheet: CSSStyleSheet | null = null;
async function populate(target: CSSStyleSheet): Promise<void> {
const parts: string[] = [];
for (const style of Array.from(document.querySelectorAll("style"))) {
parts.push(style.textContent ?? "");
}
const links = Array.from(
document.querySelectorAll<HTMLLinkElement>('link[rel="stylesheet"]'),
);
await Promise.all(
links.map(async (link) => {
try {
const response = await fetch(link.href);
if (response.ok) {
parts.push(await response.text());
}
} catch {
// Unreachable stylesheet — skip; the component renders unstyled
// rather than breaking.
}
}),
);
await target.replace(parts.join("\n"));
}
export function documentStylesSheet(): CSSStyleSheet {
if (sheet === null) {
sheet = new CSSStyleSheet();
void populate(sheet);
}
return sheet;
}
// Keep the copy in sync when Vite hot-replaces CSS in dev.
if (import.meta.hot) {
import.meta.hot.on("vite:afterUpdate", () => {
if (sheet !== null) {
void populate(sheet);
}
});
}
+3 -1
View File
@@ -1,5 +1,6 @@
export type { AttackRingInput } from "../types";
export { createDebugGui } from "./debug/index";
// createDebugGui is intentionally not re-exported here — it pulls lil-gui and
// the debug GUI into the main bundle; dynamically import "./debug/index".
export type {
GameViewEventMap,
GameViewEventType,
@@ -11,6 +12,7 @@ export type {
export { GameView } from "./GameView";
export { GraphicsOverridesSchema } from "./GraphicsOverrides";
export type { GraphicsOverrides } from "./GraphicsOverrides";
export { preloadAtlasData } from "./passes/name-pass/AtlasData";
export type { SpawnCenter } from "./passes/SpawnOverlayPass";
export {
applyDarkModeOverride,
@@ -4,7 +4,7 @@
*/
import emojiAtlasMeta from "resources/atlases/emoji-atlas-meta.json";
import atlasData from "resources/atlases/msdf-atlas.json";
import { assetUrl } from "src/core/AssetUrls";
import type { BMChar, BMKerning, ParsedAtlas } from "./Types";
import { CHAR_RANGE } from "./Types";
@@ -12,15 +12,45 @@ import { CHAR_RANGE } from "./Types";
// Atlas parsing
// ---------------------------------------------------------------------------
interface RawMsdfAtlas {
info: { size: number };
common: { base: number; scaleW: number; scaleH: number };
distanceField?: { distanceRange: number };
chars: BMChar[];
kernings?: BMKerning[];
}
// Fetched at game-load time rather than statically imported — the JSON is
// ~320 KB minified and would otherwise sit in the main bundle.
let atlasData: RawMsdfAtlas | null = null;
let atlasDataPromise: Promise<void> | null = null;
export function preloadAtlasData(): Promise<void> {
atlasDataPromise ??= fetch(assetUrl("atlases/msdf-atlas.json"))
.then((response) => {
if (!response.ok) {
throw new Error(`Failed to fetch msdf-atlas.json: ${response.status}`);
}
return response.json();
})
.then((json) => {
atlasData = json as RawMsdfAtlas;
});
return atlasDataPromise;
}
export function parseAtlasData(): ParsedAtlas {
if (atlasData === null) {
throw new Error("Atlas data not loaded; await preloadAtlasData() first");
}
return {
fontSize: atlasData.info.size,
base: atlasData.common.base,
scaleW: atlasData.common.scaleW,
scaleH: atlasData.common.scaleH,
distanceRange: (atlasData as any).distanceField?.distanceRange ?? 4,
chars: atlasData.chars as BMChar[],
kernings: (atlasData.kernings ?? []) as BMKerning[],
distanceRange: atlasData.distanceField?.distanceRange ?? 4,
chars: atlasData.chars,
kernings: atlasData.kernings ?? [],
};
}
+7 -19
View File
@@ -1,7 +1,6 @@
import { Colord, extend } from "colord";
import labPlugin from "colord/plugins/lab";
import lchPlugin from "colord/plugins/lch";
import Color from "colorjs.io";
import { PseudoRandom } from "../../core/PseudoRandom";
import { simpleHash } from "../../core/Util";
extend([lchPlugin]);
@@ -75,14 +74,12 @@ export function selectDistinctColorIndex(
throw new Error("No assigned colors");
}
const assignedLabColors = assignedColors.map(toColor);
let maxDeltaE = 0;
let maxIndex = 0;
for (let i = 0; i < availableColors.length; i++) {
const color = availableColors[i];
const deltaE = minDeltaE(toColor(color), assignedLabColors);
const deltaE = minDeltaE(color, assignedColors);
if (deltaE > maxDeltaE) {
maxDeltaE = deltaE;
maxIndex = i;
@@ -91,20 +88,11 @@ export function selectDistinctColorIndex(
return maxIndex;
}
/** Smallest delta-E 2000 distance from `lab1` to any of the assigned colors. */
function minDeltaE(lab1: Color, assignedLabColors: Color[]) {
return assignedLabColors.reduce((min, assigned) => {
return Math.min(min, deltaE2000(lab1, assigned));
/** Smallest delta-E 2000 distance from `color` to any of the assigned colors. */
function minDeltaE(color: Colord, assignedColors: Colord[]) {
return assignedColors.reduce((min, assigned) => {
// colord's lab plugin .delta() is CIEDE2000 normalized to 0..1; only
// relative magnitudes matter here.
return Math.min(min, color.delta(assigned));
}, Infinity);
}
/** Perceptual distance between two colors using the CIEDE2000 formula. */
function deltaE2000(c1: Color, c2: Color): number {
return c1.deltaE(c2, "2000");
}
/** Convert a colord color to a colorjs.io LAB color for delta-E math. */
function toColor(colord: Colord): Color {
const lab = colord.toLab();
return new Color("lab", [lab.l, lab.a, lab.b]);
}
+25 -21
View File
@@ -13,13 +13,20 @@ import { ErrorUpdate, GameUpdateViewData } from "../game/GameUpdates";
import { ClientID, GameStartInfo, Turn } from "../Schemas";
import { generateID } from "../Util";
import { WorkerMessage } from "./WorkerMessages";
// Inlined into the main bundle as a same-origin Blob, sidestepping the
// Inlined as a same-origin Blob (Vite's `?worker&inline`), sidestepping the
// cross-origin `new Worker(url)` restriction that would otherwise apply when
// the worker bundle is served from the CDN.
import GameWorker from "./Worker.worker.ts?worker&inline";
// the worker bundle is served from the CDN. The dynamic import keeps the
// ~700 KB base64 payload in its own chunk, fetched when a game starts,
// instead of inside the main bundle.
async function createGameWorker(): Promise<Worker> {
const { default: GameWorker } =
await import("./Worker.worker.ts?worker&inline");
return new GameWorker();
}
export class WorkerClient {
private worker: Worker;
private worker: Worker | null = null;
private isInitialized = false;
private messageHandlers: Map<string, (message: WorkerMessage) => void>;
private gameUpdateCallback?: (
@@ -30,14 +37,7 @@ export class WorkerClient {
private gameStartInfo: GameStartInfo,
private clientID: ClientID | undefined,
) {
this.worker = new GameWorker();
this.messageHandlers = new Map();
// Set up global message handler
this.worker.addEventListener(
"message",
this.handleWorkerMessage.bind(this),
);
}
private handleWorkerMessage(event: MessageEvent<WorkerMessage>) {
@@ -73,7 +73,11 @@ export class WorkerClient {
}
}
initialize(): Promise<void> {
async initialize(): Promise<void> {
const worker = await createGameWorker();
this.worker = worker;
worker.addEventListener("message", this.handleWorkerMessage.bind(this));
return new Promise((resolve, reject) => {
const messageId = generateID();
@@ -84,7 +88,7 @@ export class WorkerClient {
}
});
this.worker.postMessage({
worker.postMessage({
type: "init",
id: messageId,
gameStartInfo: this.gameStartInfo,
@@ -113,7 +117,7 @@ export class WorkerClient {
throw new Error("Worker not initialized");
}
this.worker.postMessage({
this.worker!.postMessage({
type: "turn",
turn,
});
@@ -137,7 +141,7 @@ export class WorkerClient {
}
});
this.worker.postMessage({
this.worker!.postMessage({
type: "player_profile",
id: messageId,
playerID: playerID,
@@ -163,7 +167,7 @@ export class WorkerClient {
}
});
this.worker.postMessage({
this.worker!.postMessage({
type: "player_border_tiles",
id: messageId,
playerID: playerID,
@@ -194,7 +198,7 @@ export class WorkerClient {
}
});
this.worker.postMessage({
this.worker!.postMessage({
type: "player_actions",
id: messageId,
playerID,
@@ -228,7 +232,7 @@ export class WorkerClient {
}
});
this.worker.postMessage({
this.worker!.postMessage({
type: "player_buildables",
id: messageId,
playerID,
@@ -274,7 +278,7 @@ export class WorkerClient {
);
});
this.worker.postMessage({
this.worker!.postMessage({
type: "attack_clustered_positions",
id: messageId,
playerID,
@@ -304,7 +308,7 @@ export class WorkerClient {
}
});
this.worker.postMessage({
this.worker!.postMessage({
type: "transport_ship_spawn",
id: messageId,
playerID: playerID,
@@ -314,7 +318,7 @@ export class WorkerClient {
}
cleanup() {
this.worker.terminate();
this.worker?.terminate();
this.messageHandlers.clear();
this.gameUpdateCallback = undefined;
}