Hi Axel — thanks for the detailed report. I dug in and can confirm what’s going on.
It’s an esbuild bug, not a Babylon Lite issue. Angular’s application builder runs esbuild with code splitting on. With splitting, esbuild emits a chunk for every statically-resolvable import(), then fails to remove those chunks after tree-shaking deletes the code that referenced them. So recast-navigation and manifold become orphan chunks — emitted, but imported by nothing. Vite/Rollup (and esbuild without splitting) drop them correctly.
Tracked upstream: Tree shaking differs between splitting and no splitting · Issue #3704 · evanw/esbuild · GitHub. A comment there with your Angular case would help it get prioritized (I’ve attached a minimal repro to it).
No runtime impact. Nothing imports these chunks, so the browser never fetches or runs them — you can confirm in DevTools → Network. The only cost is deployment size / bundle-budget warnings.
Workaround — prune orphan chunks after the build. They’re unreachable from your entry points, so you can safely delete them. In my test of a minimal Lite app this removes ~3.1 MB (245 files, incl. recast + manifold). It’s hash/version-independent since it works off the import graph.
npm i -D es-module-lexer (a real parser — regex will misread minified ESM)
- Add
prune-orphans.mjs:
// usage: node prune-orphans.mjs <browser-output-dir>
import { readdirSync, readFileSync, statSync, rmSync, existsSync } from "node:fs";
import { join, basename } from "node:path";
import { init, parse } from "es-module-lexer";
await init;
const dir = process.argv[2];
if (!dir) { console.error("usage: node prune-orphans.mjs <dir>"); process.exit(1); }
const jsFiles = new Set(readdirSync(dir).filter(f => f.endsWith(".js")));
const importsOf = (file) => {
const [imports] = parse(readFileSync(join(dir, file), "utf8"));
return imports.filter(im => im.n && jsFiles.has(basename(im.n))).map(im => basename(im.n));
};
// roots = every .js referenced by the HTML entries (<script> + modulepreload)
const roots = new Set();
for (const html of readdirSync(dir).filter(f => f.endsWith(".html")))
for (const m of readFileSync(join(dir, html), "utf8").matchAll(/(?:src|href)\s*=\s*["']([^"']+\.js)["']/g))
if (jsFiles.has(basename(m[1]))) roots.add(basename(m[1]));
if (!roots.size) { console.error("No entry scripts in HTML — aborting."); process.exit(1); }
const reachable = new Set(roots), queue = [...roots];
while (queue.length) for (const dep of importsOf(queue.pop()))
if (!reachable.has(dep)) { reachable.add(dep); queue.push(dep); }
let deleted = 0, freed = 0;
for (const f of jsFiles) {
if (reachable.has(f)) continue;
freed += statSync(join(dir, f)).size;
rmSync(join(dir, f), { force: true }); rmSync(join(dir, f + ".map"), { force: true });
deleted++;
}
// safety net: nothing kept may reference something deleted
let dangling = 0;
for (const f of readdirSync(dir).filter(x => x.endsWith(".js")))
for (const dep of importsOf(f))
if (!existsSync(join(dir, dep))) { console.error("DANGLING", f, "->", dep); dangling++; }
console.log(`Pruned ${deleted} chunks, freed ${(freed/1048576).toFixed(2)} MB. Dangling: ${dangling}.`);
if (dangling) process.exit(2);
- Run it after the build:
"scripts": { "build": "ng build && node prune-orphans.mjs dist/<your-project>/browser" }
Caveats: if you use a service worker (@angular/pwa), run the prune before the ngsw manifest is generated; and smoke-test once after adding it (your own lazy routes stay, since they’re reachable).
Hope that helps!