From 9aec53b8fbb5562a473cc6175afb315572b77446 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:48:11 -0400 Subject: [PATCH] fix(@angular/build): consolidate initial shared chunks into main with Rolldown When esbuild bundles an application with lazy-loaded routes, modules that are statically reachable from the main entry point and also imported by different subsets of lazy routes are split into separate shared chunks, often forming multi-level import chains from main.js. Previously, the Rollup-based chunk optimization pass recognized that the main entry point is always loaded before any dynamic import can execute and merged all modules in its static closure back into main.js. After switching chunk optimization to Rolldown, unconfigured Rolldown only merged direct single-hop shared chunks into main.js while leaving deeper initial shared chunks as separate files. Configuring Rolldown with preserveEntrySignatures set to allow-extension and a codeSplitting group targeting the built-in $initial tag restores the consolidation of all initial shared modules into the main entry chunk while keeping lazy-only shared chunks separate. --- .../builders/application/chunk-optimizer.ts | 38 ++++- .../chunk-optimization-server_spec.ts | 144 ++++++++++++++++++ 2 files changed, 177 insertions(+), 5 deletions(-) diff --git a/packages/angular/build/src/builders/application/chunk-optimizer.ts b/packages/angular/build/src/builders/application/chunk-optimizer.ts index cd5e4e1814ec..053f62f80868 100644 --- a/packages/angular/build/src/builders/application/chunk-optimizer.ts +++ b/packages/angular/build/src/builders/application/chunk-optimizer.ts @@ -296,19 +296,48 @@ export async function optimizeChunks( }, ]; + const chunkFileNames = (chunkInfo: { name: string }) => + `${chunkInfo.name.replace(/-[a-zA-Z0-9_-]{8}$/, '')}-[hash].js`; + if (useRolldownChunks) { const { rolldown } = await loadRolldown(); bundle = await rolldown({ input: mainFile, + // Allow Rolldown to extend the main entry chunk's exports when merging shared initial + // modules into `main` so that lazy chunks can import those symbols directly from `main`. + preserveEntrySignatures: 'allow-extension', plugins, }); - const result = await bundle.generate({ + let result = await bundle.generate({ minify: { mangle: false, compress: false }, sourcemap, - chunkFileNames: (chunkInfo) => - `${chunkInfo.name.replace(/-[a-zA-Z0-9_-]{8}$/, '')}-[hash].js`, + codeSplitting: { + // Consolidate all modules statically reachable from the initial entry point (`$initial`) + // back into the `main` chunk. This prevents multi-level initial chunk waterfalls caused + // by esbuild splitting out modules that are shared across `main` and subsets of lazy + // routes, while keeping shared chunks used exclusively by lazy routes separate. + groups: [{ name: 'main', tags: ['$initial'] }], + }, + chunkFileNames, }); + + // If any module in `$initial` uses top-level `await`, Rolldown cannot fold the manual + // `main` group into the entry chunk and emits an empty entry facade instead. Fall back to + // Rolldown's default TLA-aware chunking in that case. + if ( + result.output.some( + (chunk) => + chunk.type === 'chunk' && chunk.isEntry && Object.keys(chunk.modules).length === 0, + ) + ) { + result = await bundle.generate({ + minify: { mangle: false, compress: false }, + sourcemap, + chunkFileNames, + }); + } + optimizedOutput = result.output; } else { let rollup; @@ -328,8 +357,7 @@ export async function optimizeChunks( const result = await bundle.generate({ compact: true, sourcemap, - chunkFileNames: (chunkInfo) => - `${chunkInfo.name.replace(/-[a-zA-Z0-9_-]{8}$/, '')}-[hash].js`, + chunkFileNames, }); optimizedOutput = result.output; } diff --git a/packages/angular/build/src/builders/application/tests/behavior/chunk-optimization-server_spec.ts b/packages/angular/build/src/builders/application/tests/behavior/chunk-optimization-server_spec.ts index 18b92e7690a0..3bdef34cae06 100644 --- a/packages/angular/build/src/builders/application/tests/behavior/chunk-optimization-server_spec.ts +++ b/packages/angular/build/src/builders/application/tests/behavior/chunk-optimization-server_spec.ts @@ -187,5 +187,149 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { .toBeTrue(); } }); + + it('consolidates multi-level initial shared modules into main while preserving lazy-only shared chunks', async () => { + await harness.writeFiles({ + 'src/shared-1.ts': ` + import { shared2 } from './shared-2'; + export function shared1(): string { + return 's1-' + shared2(); + } + `, + 'src/shared-2.ts': ` + import { shared3 } from './shared-3'; + export function shared2(): string { + return 's2-' + shared3(); + } + `, + 'src/shared-3.ts': ` + import { shared4 } from './shared-4'; + export function shared3(): string { + return 's3-' + shared4(); + } + `, + 'src/shared-4.ts': ` + export function shared4(): string { + return 's4-' + Date.now().toString(36); + } + `, + 'src/lazy-shared.ts': ` + export function lazyOnlyShared(): string { + return 'lazy-only-' + Date.now().toString(36); + } + `, + 'src/app/app.component.ts': ` + import { Component } from '@angular/core'; + import { RouterOutlet } from '@angular/router'; + import { shared1 } from '../shared-1'; + + @Component({ + selector: 'app-root', + imports: [RouterOutlet], + template: '

{{ val }}

', + }) + export class AppComponent { + val = shared1(); + } + `, + 'src/app/lazy-a.component.ts': ` + import { Component } from '@angular/core'; + import { shared1 } from '../shared-1'; + import { lazyOnlyShared } from '../lazy-shared'; + + @Component({ selector: 'app-lazy-a', template: '{{ val }}' }) + export default class LazyAComponent { + val = shared1() + lazyOnlyShared(); + } + `, + 'src/app/lazy-b.component.ts': ` + import { Component } from '@angular/core'; + import { shared2 } from '../shared-2'; + import { lazyOnlyShared } from '../lazy-shared'; + + @Component({ selector: 'app-lazy-b', template: '{{ val }}' }) + export default class LazyBComponent { + val = shared2() + lazyOnlyShared(); + } + `, + 'src/app/lazy-c.component.ts': ` + import { Component } from '@angular/core'; + import { shared3 } from '../shared-3'; + + @Component({ selector: 'app-lazy-c', template: '{{ val }}' }) + export default class LazyCComponent { + val = shared3(); + } + `, + 'src/app/lazy-d.component.ts': ` + import { Component } from '@angular/core'; + import { shared4 } from '../shared-4'; + + @Component({ selector: 'app-lazy-d', template: '{{ val }}' }) + export default class LazyDComponent { + val = shared4(); + } + `, + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + polyfills: ['zone.js'], + optimization: true, + namedChunks: true, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + const mainContent = harness.readFile('dist/browser/main.js'); + expect(mainContent).toContain('s1-'); + expect(mainContent).toContain('s2-'); + expect(mainContent).toContain('s3-'); + expect(mainContent).toContain('s4-'); + expect(mainContent).not.toContain('lazy-only-'); + + // No modulepreload hints should be needed in index.html because all initial shared + // modules (shared-1 through shared-4) were consolidated into main.js. + const indexContent = harness.readFile('dist/browser/index.html'); + expect(indexContent).not.toContain('rel="modulepreload"'); + + // The lazy-only shared module should still be emitted as a separate shared chunk. + expect(harness.hasFileMatch('dist/browser', /^chunk-/)).toBeTrue(); + }); + + it('falls back to TLA-safe chunking without emitting an empty main facade when top-level await is used', async () => { + await harness.writeFiles({ + 'src/main.ts': ` + import { provideZonelessChangeDetection } from '@angular/core'; + import { bootstrapApplication } from '@angular/platform-browser'; + import { AppComponent } from './app/app.component'; + import { appConfig } from './app/app.config'; + + await Promise.resolve(); + console.log('main-tla-entry'); + await bootstrapApplication(AppComponent, { + ...appConfig, + providers: [...appConfig.providers, provideZonelessChangeDetection()], + }); + `, + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + polyfills: [], + optimization: true, + namedChunks: true, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + // main.js should contain the entry point code directly rather than being an empty + // facade importing a separate `main-.js` chunk. + expect(harness.hasFileMatch('dist/browser', /^main-/)).toBeFalse(); + const mainContent = harness.readFile('dist/browser/main.js'); + expect(mainContent).toContain('main-tla-entry'); + }); }); });