[Babylon Lite] Treeshakable deep imports

On my journey of integrating Babylon Lite with Angular, I found out that I get two big chunks in my bundle: recast-navigation and manifold. It looks like in a plain vite project, the bundler has no trouble optimizing these out, but for angular with esbuild, looks like these are included. I remember with classic Babylon, I always used deep imports to make sure I only import what is required in my application since using barrel imports always dramatically increased the bundle size.

Do you have plans to also have deep imports for Babylon Lite to support bundlers in more challenging situations?

Best regards,
Axel

cc @RaananW

Babylon lite is very bundler friendly, as it tree-shakes perfectly. No need for a deep import - your bundler should be able to filter out unused functions and throw them out of the final dist bundle. Lite h as no side effects and is functional in its base. the perfect combination for perfect tree-shaking.

Note that some bundlers only do that in production mode. It is working this way so you can iterate fast during development, and have the best package in production.

Not unless we find real concrete reasons that demand it. One of the problems with allowing deep imports is that we “ship our directory structure”, which means we can’t really change it in the future (rename or move files or directories), which can be problematic as the codebase grows and evolves.

We could try to repro with ESBuild, or if you already have a small ESBuild based repro you could share that would be great.

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.

  1. npm i -D es-module-lexer (a real parser — regex will misread minified ESM)
  2. 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);
  1. 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!

Hello @ryantrem,
thank you for the detailed analysis and really digging deep on this. I was mostly describing symptoms. For now, for me personally, there is no need to work around this as I am testing with the purpose of finding these things and steer early.

I added a comment next to your comment in the esbuild ticket. I hope it will get a bit of traction again.

Best regards,
Axel

@axeljaeger I found a workaround that doesn’t put an excessive ~370 files totaling ~ 3.4MB in your /dist folder. It won’t increase your total bundle size, either. Where is the best place to put my code? GitHub?

Share it here or create a github gist.