Keep rarely-used modules reachable only through a dynamic import(), which Rollup always
splits into its own chunk, fetched on demand. Callers reach them through a typed registry
that hides the import entirely.
src/utils/registry.ts exports a layer-agnostic deep module — createRegistry(loaders) — that turns
a map of loader thunks into a callable, typed object. Accessing Registry.Name.method(...args) loads
the owning module (its own chunk, evaluated at most once), then dispatches the call, always
returning a Promise. Callers never write import() or .then(m => …). An eager(value) helper
registers an already-imported value so lazy vs eager is invisible to callers.
Two buckets are built from the factory, each in its own layer's eager entry, and exposed on window
(so legacy public/modules/**/*.js and inline onclick handlers can reach them, mirroring the old
window.lazy):
Both buckets use the same contract: every registered module exports a single named object whose properties are its public methods.
Controllers(insrc/controllers/index.ts) — dialog controllers (editors, overviews, tools). Each entry resolves to the module'sModuleTypeexport:Controllers.MarketOverview.open(id).Services(insrc/services/index.ts) — service- and IO-layer app-shell modules. IO lives undersrc/services/io/. Each module likewise exports one object (Save,Load,ExportMap,ExportJson,Installation,CloudStorage,UiTour):Services.Save.toMachine().
The mechanism is dispatch-only: callers invoke methods, they don't read properties off the
resolved object. A module that exposes data or a nested object must wrap it in a method facade (e.g.
CloudStorage flattens Cloud.providers.dropbox), OR be loaded eagerly instead (e.g. the small
credits string is exposed as window.supporters via src/data/index.ts).
Both index.ts files are eager <script type="module"> entries in index.html, so the registries
exist at startup. They hold only loader thunks, so the eager cost is a few bytes, not module bodies.
-
Write the module normally. Plain
.tsfile under the layer it belongs to (controllers/,services/,services/io/,data/), named exports, fully typed. Controllers exportexport const <Name> = { open, ... }. -
Never import it statically from anything in the eager graph reachable from
controllers/index.ts/generators/index.ts/renderers/index.ts— that graph is the main chunk. (The loader thunk'simport()does not count as static.) -
Add one line to the right bucket —
Controllersincontrollers/index.ts,Servicesinservices/index.ts:// Controllers: resolve to the module's single exported object MarketOverview: () => import("@/controllers/market-overview").then(m => m.MarketOverview), // Services: same contract — resolve to the module's single exported object Save: () => import("@/services/io/save").then(m => m.Save),
Rollup sees the string literal inside
import()and emits an independent chunk. Each entry keeps its exact resolved type, so call sites are fully typed and autocompleted. -
Call it uniformly. Migrated TS imports the bucket; legacy JS uses the
windowglobal:import { Controllers } from "@/controllers"; Controllers.MarketOverview.open(id); // returns Promise<void>
window.Controllers.MarketOverview.open(id);
-
Delete the old
.jsfile frompublic/modules/once ported.
A loader is just () => Promise<resolved>. To register an already-imported (eager) module, wrap it
with eager(value) from registry.ts — it resolves on the next microtask, so callers can't tell it
isn't lazy. Switching a module between lazy and eager is a one-line change in index.ts; no call
site changes. (All controllers are currently lazy; eager exists for future tuning and Services.)
- Named exports only — no module-level
window.X = new Thing()self-registration for lazy modules (that pattern is for eagerly-loaded generators likemarkets-generator.ts). - The registry buckets in
index.tscontain only() => import(...)thunks — no logic. The moment a controller is imported statically there, it stops being lazy. - An entry must never be made thenable: the factory's per-entry proxy returns
undefinedforthenand for symbol keys, soawait Registry.Nameis a no-op rather than a phantom method call. Keep that guard if you touchregistry.ts. - If a lazy module needs another not-yet-declared global, add it to
src/types/global.tsunder the existing ambient-global convention (seesrc/components/globals.ts) — don'tas anyaround it. - Use d3 v7 via named imports (
import { select } from "d3"), not thewindow.d3global. See migration-guide.md.
npm run build
ls dist/assets | grep market-overview # expect market-overview-<hash>.js as its own fileIf the module's code shows up inside the main entry chunk instead of its own file, something in the
eager graph is importing it statically — check controllers/index.ts / generators/index.ts and
anything they pull in.