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'); + }); }); });