Skip to content

feat(@angular/build): poc - shrink @defer chunks that pull in whole libraries - #33974

Closed
arturovt wants to merge 1 commit into
angular:mainfrom
arturovt:poc/defer-dependency-tree-shaking
Closed

feat(@angular/build): poc - shrink @defer chunks that pull in whole libraries#33974
arturovt wants to merge 1 commit into
angular:mainfrom
arturovt:poc/defer-dependency-tree-shaking

Conversation

@arturovt

Copy link
Copy Markdown
Contributor

This is a prototype, not a finished fix. Nothing here is meant to be merged as-is.

The problem: if you use @defer with a component from an npm package, Angular can end up putting the WHOLE package into your app's deferred chunk, even if you only use one small part of it. That makes your app bigger than it needs to be.

Why it happens: Angular writes code like this for defer blocks:

import("some-lib").then(m => m.MyThing)

The import() part always loads the ENTIRE package. There's no way to ask it for just one piece. So the bundler (esbuild) can't remove the parts you're not using, because it can't be sure nothing else needs them.

What this branch tries: a small esbuild plugin that spots this pattern and swaps it for a plain export line instead:

export { MyThing } from 'some-lib';

That's just a normal export. esbuild CAN remove the unused parts of the package once it sees that.

Does it actually work? Yes, but how much it helps depends a lot on the package:

  • Best case (a package made of fully separate pieces): about 88% smaller in a test.
  • Real test with ngx-markdown: only about 1.5% smaller, because most of what MarkdownComponent needs turned out to be stuff it can't avoid anyway.
  • Found a real bug while testing: for old-style CommonJS packages (tried it with lodash), this trick does not help at all, and was actually making the output a little bigger. Added a check so those packages get skipped now instead.

What's tested: unit tests for the pattern matching, the rewrite step, and the esbuild plugin itself. 25 tests, all passing.

What's still missing, on purpose:

  • Only wired into the browser build, not the server/SSR build.
  • Sourcemaps are not fully wired end to end yet.
  • Not tested against real incremental/watch-mode rebuilds.
  • Biggest open question: this works by guessing based on what the compiler's output looks like today. It would be safer if the compiler team could give us something more solid to look for instead of guessing. See the draft write-up for more on this.

Full write-up: defer-dependency-tree-shaking.pr-draft.md.

…ibraries

This is a prototype, not a finished fix. Nothing here is meant to be
merged as-is.

The problem: if you use @defer with a component from an npm package,
Angular can end up putting the WHOLE package into your app's deferred
chunk, even if you only use one small part of it. That makes your app
bigger than it needs to be.

Why it happens: Angular writes code like this for defer blocks:

  import("some-lib").then(m => m.MyThing)

The import() part always loads the ENTIRE package. There's no way to
ask it for just one piece. So the bundler (esbuild) can't remove the
parts you're not using, because it can't be sure nothing else needs
them.

What this branch tries: a small esbuild plugin that spots this pattern
and swaps it for a plain export line instead:

  export { MyThing } from 'some-lib';

That's just a normal export. esbuild CAN remove the unused parts of
the package once it sees that.

Does it actually work? Yes, but how much it helps depends a lot on the
package:
- Best case (a package made of fully separate pieces): about 88%
  smaller in a test.
- Real test with ngx-markdown: only about 1.5% smaller, because most
  of what MarkdownComponent needs turned out to be stuff it can't
  avoid anyway.
- Found a real bug while testing: for old-style CommonJS packages
  (tried it with lodash), this trick does not help at all, and was
  actually making the output a little bigger. Added a check so those
  packages get skipped now instead.

What's tested: unit tests for the pattern matching, the rewrite step,
and the esbuild plugin itself. 25 tests, all passing.

What's still missing, on purpose:
- Only wired into the browser build, not the server/SSR build.
- Sourcemaps are not fully wired end to end yet.
- Not tested against real incremental/watch-mode rebuilds.
- Biggest open question: this works by guessing based on what the
  compiler's output looks like today. It would be safer if the
  compiler team could give us something more solid to look for
  instead of guessing. See the draft write-up for more on this.

Full write-up: defer-dependency-tree-shaking.pr-draft.md
@angular-robot angular-robot Bot added detected: feature PR contains a feature commit area: @angular/build labels Aug 31, 2026
@alan-agius4

Copy link
Copy Markdown
Collaborator

Thanks for putting this together! This is actually a known limitation within esbuild itself rather than our codebase.

You can follow the ongoing discussion here:

If you’d like to help resolve it, contributions to the esbuild repo would be greatly appreciated!

@arturovt

Copy link
Copy Markdown
Contributor Author

thanks @alan-agius4

I want to flag one thing though: I don't think this actually depends on esbuild#3987, and I want to make sure that's not why it got closed.

That esbuild issue is asking the tree-shaker to look inside a dynamic import and figure out which property gets read off the result afterward (const { foo } = await import(...)) — esbuild doesn't do that today, that part's true.

But this PR doesn't rely on that. It rewrites the code so it never needs it:

// what the compiler emits today - needs the esbuild feature that doesn't exist:
import("ngx-markdown").then(m => m.MarkdownComponent)

// what the plugin turns it into - just a static re-export, which esbuild
// has always been able to tree-shake, no engine change needed:
export { MarkdownComponent } from 'ngx-markdown';

That's ordinary ESM tree-shaking, not something waiting on esbuild. I checked this three different ways in the PR (real ngx-markdown numbers, a synthetic worst-case showing 88% off, and byte-identical output vs. a hand-written static import) - it's not theoretical, it's the 25 tests in the diff.

@arturovt

Copy link
Copy Markdown
Contributor Author

would you be able to discuss it with the team and see if there's interest in revisiting this?

@alan-agius4

Copy link
Copy Markdown
Collaborator

Tree-shaking behavior varies by bundler. Rolldown, for example, successfully tree-shakes unused exports even with dynamic import patterns like import('ngx-markdown').then(m => m.MarkdownComponent).

Another concern with this PR is long-term viability. We need to move away from TypeScript based transformations, as they won't be supported once we integrate tsgo. Even if the underlying fix is sound, the architectural approach needs to change.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: @angular/build detected: feature PR contains a feature commit

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants