fuzzyworld

tower / commit

perf(exterior): defer Three.js so the map does not pay for it

aa55ae67fc2be2ac0e152a29be58f412d12bf968 · Pavel Safronov · 2026-09-06 20:32:00 -0700 · parents 5f38efd0d4ca

perf(exterior): defer Three.js so the map does not pay for it

The previous commit claimed Three.js loaded on first use of the exterior
view. It did not — the import was static, so it was bundled into the entry
chunk and downloaded by everyone, including a reader who only ever opens the
map. Only the ExteriorView instance was deferred, not the library.

A dynamic import makes the claim true. The entry chunk drops from ~540 kB to
28 kB (10 kB gzipped) and Three.js becomes a 515 kB chunk fetched on the
first click of Exterior. Verified in the browser: no three/exterior request
before opening the view, one after.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
 src/ui/app.ts  | 10 +++++++---
 vite.config.ts |  8 +++++++-
 2 files changed, 14 insertions(+), 4 deletions(-)

diff --git a/src/ui/app.ts b/src/ui/app.ts
index 7d89b85..25bcac5 100644
--- a/src/ui/app.ts
+++ b/src/ui/app.ts
@@ -15,7 +15,7 @@ import {
 import { KIND_COLOR } from '../views/palette.js'
 import { WorldStore, backupFilename, download } from '../world/store.js'
 import { TowerMap } from '../views/map.js'
-import { ExteriorView } from '../views/exterior.js'
+import type { ExteriorView } from '../views/exterior.js'
 import { SPIN_PERIOD_S } from '../canon/constants.js'
 
 type Tool = 'select' | PlaceKind
@@ -97,7 +97,7 @@ export class App {
 
   private buildViews(): void {
     for (const b of document.querySelectorAll<HTMLButtonElement>('#views button')) {
-      b.onclick = () => this.setView(b.dataset['view'] as ViewName)
+      b.onclick = () => { void this.setView(b.dataset['view'] as ViewName) }
     }
     const spin = $<HTMLInputElement>('f-spin')
     const speed = $<HTMLInputElement>('f-speed')
@@ -120,7 +120,7 @@ export class App {
    * animation loop, neither of which a reader who only ever opens the map should pay
    * for.
    */
-  private setView(view: ViewName): void {
+  private async setView(view: ViewName): Promise<void> {
     this.view = view
     const isMap = view === 'map'
     $('map').classList.toggle('hidden', isMap ? false : true)
@@ -137,6 +137,10 @@ export class App {
       return
     }
     if (!this.exterior) {
+      // Genuinely deferred: Three.js is by far the largest dependency, and a static
+      // import would bundle it into the entry chunk whether or not this view is
+      // ever opened. Vite splits it into its own chunk on this await.
+      const { ExteriorView } = await import('../views/exterior.js')
       this.exterior = new ExteriorView($<HTMLCanvasElement>('exterior'), {
         onPickPoint: (p) => {
           this.map.probe = { from: p, to: null }
diff --git a/vite.config.ts b/vite.config.ts
index 852945a..10d1b0e 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -21,5 +21,11 @@ export default defineConfig({
   // `mv dist public` does not rename when `public/` already exists, it moves dist
   // *inside* it. The collision is silent and produces a 404 at the site root.
   publicDir: 'static',
-  build: { target: 'es2022' },
+  build: {
+    target: 'es2022',
+    // Three.js is ~515 kB and is deliberately split into its own chunk, loaded
+    // only when the exterior view is opened. Raise the threshold past it so the
+    // warning stays meaningful for chunks that are large by accident.
+    chunkSizeWarningLimit: 600,
+  },
 })