Skip to content

Commit d6d8b8f

Browse files
committed
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.
1 parent 0fd4404 commit d6d8b8f

2 files changed

Lines changed: 120 additions & 0 deletions

File tree

‎packages/angular/build/src/builders/application/chunk-optimizer.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,12 +300,22 @@ export async function optimizeChunks(
300300
const { rolldown } = await loadRolldown();
301301
bundle = await rolldown({
302302
input: mainFile,
303+
// Allow Rolldown to extend the main entry chunk's exports when merging shared initial
304+
// modules into `main` so that lazy chunks can import those symbols directly from `main`.
305+
preserveEntrySignatures: 'allow-extension',
303306
plugins,
304307
});
305308

306309
const result = await bundle.generate({
307310
minify: { mangle: false, compress: false },
308311
sourcemap,
312+
codeSplitting: {
313+
// Consolidate all modules statically reachable from the initial entry point (`$initial`)
314+
// back into the `main` chunk. This prevents multi-level initial chunk waterfalls caused
315+
// by esbuild splitting out modules that are shared across `main` and subsets of lazy
316+
// routes, while keeping shared chunks used exclusively by lazy routes separate.
317+
groups: [{ name: 'main', tags: ['$initial'] }],
318+
},
309319
chunkFileNames: (chunkInfo) =>
310320
`${chunkInfo.name.replace(/-[a-zA-Z0-9_-]{8}$/, '')}-[hash].js`,
311321
});

‎packages/angular/build/src/builders/application/tests/behavior/chunk-optimization-server_spec.ts‎

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,5 +187,115 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => {
187187
.toBeTrue();
188188
}
189189
});
190+
191+
it('consolidates multi-level initial shared modules into main while preserving lazy-only shared chunks', async () => {
192+
await harness.writeFiles({
193+
'src/shared-1.ts': `
194+
import { shared2 } from './shared-2';
195+
export function shared1(): string {
196+
return 's1-' + shared2();
197+
}
198+
`,
199+
'src/shared-2.ts': `
200+
import { shared3 } from './shared-3';
201+
export function shared2(): string {
202+
return 's2-' + shared3();
203+
}
204+
`,
205+
'src/shared-3.ts': `
206+
import { shared4 } from './shared-4';
207+
export function shared3(): string {
208+
return 's3-' + shared4();
209+
}
210+
`,
211+
'src/shared-4.ts': `
212+
export function shared4(): string {
213+
return 's4-' + Date.now().toString(36);
214+
}
215+
`,
216+
'src/lazy-shared.ts': `
217+
export function lazyOnlyShared(): string {
218+
return 'lazy-only-' + Date.now().toString(36);
219+
}
220+
`,
221+
'src/app/app.component.ts': `
222+
import { Component } from '@angular/core';
223+
import { RouterOutlet } from '@angular/router';
224+
import { shared1 } from '../shared-1';
225+
226+
@Component({
227+
selector: 'app-root',
228+
imports: [RouterOutlet],
229+
template: '<p>{{ val }}</p><router-outlet></router-outlet>',
230+
})
231+
export class AppComponent {
232+
val = shared1();
233+
}
234+
`,
235+
'src/app/lazy-a.component.ts': `
236+
import { Component } from '@angular/core';
237+
import { shared1 } from '../shared-1';
238+
import { lazyOnlyShared } from '../lazy-shared';
239+
240+
@Component({ selector: 'app-lazy-a', template: '{{ val }}' })
241+
export default class LazyAComponent {
242+
val = shared1() + lazyOnlyShared();
243+
}
244+
`,
245+
'src/app/lazy-b.component.ts': `
246+
import { Component } from '@angular/core';
247+
import { shared2 } from '../shared-2';
248+
import { lazyOnlyShared } from '../lazy-shared';
249+
250+
@Component({ selector: 'app-lazy-b', template: '{{ val }}' })
251+
export default class LazyBComponent {
252+
val = shared2() + lazyOnlyShared();
253+
}
254+
`,
255+
'src/app/lazy-c.component.ts': `
256+
import { Component } from '@angular/core';
257+
import { shared3 } from '../shared-3';
258+
259+
@Component({ selector: 'app-lazy-c', template: '{{ val }}' })
260+
export default class LazyCComponent {
261+
val = shared3();
262+
}
263+
`,
264+
'src/app/lazy-d.component.ts': `
265+
import { Component } from '@angular/core';
266+
import { shared4 } from '../shared-4';
267+
268+
@Component({ selector: 'app-lazy-d', template: '{{ val }}' })
269+
export default class LazyDComponent {
270+
val = shared4();
271+
}
272+
`,
273+
});
274+
275+
harness.useTarget('build', {
276+
...BASE_OPTIONS,
277+
polyfills: ['zone.js'],
278+
optimization: true,
279+
namedChunks: true,
280+
});
281+
282+
const { result } = await harness.executeOnce();
283+
expect(result?.success).toBeTrue();
284+
285+
const mainContent = harness.readFile('dist/browser/main.js');
286+
expect(mainContent).toContain('s1-');
287+
expect(mainContent).toContain('s2-');
288+
expect(mainContent).toContain('s3-');
289+
expect(mainContent).toContain('s4-');
290+
expect(mainContent).not.toContain('lazy-only-');
291+
292+
// No modulepreload hints should be needed in index.html because all initial shared
293+
// modules (shared-1 through shared-4) were consolidated into main.js.
294+
const indexContent = harness.readFile('dist/browser/index.html');
295+
expect(indexContent).not.toContain('rel="modulepreload"');
296+
297+
// The lazy-only shared module should still be emitted as a separate shared chunk.
298+
expect(harness.hasFileMatch('dist/browser', /^chunk-/)).toBeTrue();
299+
});
190300
});
191301
});

0 commit comments

Comments
 (0)