Skip to content

Commit 97efdcf

Browse files
clydinalan-agius4
authored andcommitted
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 d69dcec commit 97efdcf

2 files changed

Lines changed: 177 additions & 5 deletions

File tree

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

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -281,19 +281,48 @@ export async function optimizeChunks(
281281
},
282282
];
283283

284+
const chunkFileNames = (chunkInfo: { name: string }) =>
285+
`${chunkInfo.name.replace(/-[a-zA-Z0-9_-]{8}$/, '')}-[hash].js`;
286+
284287
if (useRolldownChunks) {
285288
const { rolldown } = await loadRolldown();
286289
bundle = await rolldown({
287290
input: mainFile,
291+
// Allow Rolldown to extend the main entry chunk's exports when merging shared initial
292+
// modules into `main` so that lazy chunks can import those symbols directly from `main`.
293+
preserveEntrySignatures: 'allow-extension',
288294
plugins,
289295
});
290296

291-
const result = await bundle.generate({
297+
let result = await bundle.generate({
292298
minify: { mangle: false, compress: false },
293299
sourcemap,
294-
chunkFileNames: (chunkInfo) =>
295-
`${chunkInfo.name.replace(/-[a-zA-Z0-9_-]{8}$/, '')}-[hash].js`,
300+
codeSplitting: {
301+
// Consolidate all modules statically reachable from the initial entry point (`$initial`)
302+
// back into the `main` chunk. This prevents multi-level initial chunk waterfalls caused
303+
// by esbuild splitting out modules that are shared across `main` and subsets of lazy
304+
// routes, while keeping shared chunks used exclusively by lazy routes separate.
305+
groups: [{ name: 'main', tags: ['$initial'] }],
306+
},
307+
chunkFileNames,
296308
});
309+
310+
// If any module in `$initial` uses top-level `await`, Rolldown cannot fold the manual
311+
// `main` group into the entry chunk and emits an empty entry facade instead. Fall back to
312+
// Rolldown's default TLA-aware chunking in that case.
313+
if (
314+
result.output.some(
315+
(chunk) =>
316+
chunk.type === 'chunk' && chunk.isEntry && Object.keys(chunk.modules).length === 0,
317+
)
318+
) {
319+
result = await bundle.generate({
320+
minify: { mangle: false, compress: false },
321+
sourcemap,
322+
chunkFileNames,
323+
});
324+
}
325+
297326
optimizedOutput = result.output;
298327
} else {
299328
let rollup;
@@ -313,8 +342,7 @@ export async function optimizeChunks(
313342
const result = await bundle.generate({
314343
compact: true,
315344
sourcemap,
316-
chunkFileNames: (chunkInfo) =>
317-
`${chunkInfo.name.replace(/-[a-zA-Z0-9_-]{8}$/, '')}-[hash].js`,
345+
chunkFileNames,
318346
});
319347
optimizedOutput = result.output;
320348
}

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

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,5 +187,149 @@ 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+
});
300+
301+
it('falls back to TLA-safe chunking without emitting an empty main facade when top-level await is used', async () => {
302+
await harness.writeFiles({
303+
'src/main.ts': `
304+
import { provideZonelessChangeDetection } from '@angular/core';
305+
import { bootstrapApplication } from '@angular/platform-browser';
306+
import { AppComponent } from './app/app.component';
307+
import { appConfig } from './app/app.config';
308+
309+
await Promise.resolve();
310+
console.log('main-tla-entry');
311+
await bootstrapApplication(AppComponent, {
312+
...appConfig,
313+
providers: [...appConfig.providers, provideZonelessChangeDetection()],
314+
});
315+
`,
316+
});
317+
318+
harness.useTarget('build', {
319+
...BASE_OPTIONS,
320+
polyfills: [],
321+
optimization: true,
322+
namedChunks: true,
323+
});
324+
325+
const { result } = await harness.executeOnce();
326+
expect(result?.success).toBeTrue();
327+
328+
// main.js should contain the entry point code directly rather than being an empty
329+
// facade importing a separate `main-<hash>.js` chunk.
330+
expect(harness.hasFileMatch('dist/browser', /^main-/)).toBeFalse();
331+
const mainContent = harness.readFile('dist/browser/main.js');
332+
expect(mainContent).toContain('main-tla-entry');
333+
});
190334
});
191335
});

0 commit comments

Comments
 (0)