diff --git a/packages/angular/build/src/builders/application/options.ts b/packages/angular/build/src/builders/application/options.ts index b3c180843f70..abe3298aa093 100644 --- a/packages/angular/build/src/builders/application/options.ts +++ b/packages/angular/build/src/builders/application/options.ts @@ -126,6 +126,20 @@ interface InternalOptions { * Suppress build summary and stats table. */ quiet?: boolean; + + /** + * Disables esbuild code splitting for the browser code bundle. + * + * Splitting emits shared chunks whose exports are read across chunk boundaries as live ESM + * bindings. A module hoisted into a shared chunk is wrapped in a lazy initializer, so its exported + * value is only assigned once that initializer runs. Runners that load the generated output + * through a module runner rather than the browser's own ESM implementation do not reliably + * preserve those bindings, and an importing chunk can observe the export as `undefined`. + * + * Test bundles are never downloaded by a browser, so there is nothing for splitting to optimize + * there. Used exclusively for tests and shouldn't be used for other kinds of builds. + */ + disableCodeSplitting?: boolean; } /** Full set of options for `application` builder. */ @@ -439,6 +453,7 @@ export async function normalizeOptions( partialSSRBuild = false, externalRuntimeStyles, instrumentForCoverage, + disableCodeSplitting, } = options; // Return all the normalized options @@ -475,6 +490,7 @@ export async function normalizeOptions( watch, workspaceRoot, entryPoints, + disableCodeSplitting, optimizationOptions, outputOptions, outExtension, diff --git a/packages/angular/build/src/builders/unit-test/runners/vitest/build-options.ts b/packages/angular/build/src/builders/unit-test/runners/vitest/build-options.ts index 7f0f67fc0c2e..3936f44b09fd 100644 --- a/packages/angular/build/src/builders/unit-test/runners/vitest/build-options.ts +++ b/packages/angular/build/src/builders/unit-test/runners/vitest/build-options.ts @@ -257,6 +257,12 @@ export async function getVitestBuildOptions( outputHashing: adjustOutputHashing(baseBuildOptions.outputHashing), optimization: false, entryPoints, + // Every spec file is its own entry point, so splitting hoists any module shared between two + // specs into a chunk whose exports are then read across a chunk boundary. Those reads rely on + // live ESM bindings, and a module placed in a shared chunk is only assigned its exported value + // when that chunk's lazy initializer runs, so an importing chunk can read `undefined`. Nothing + // downloads these bundles, so there is no benefit to weigh against that. + disableCodeSplitting: true, // Enable support for vitest browser prebundling. Excludes can be controlled with a runnerConfig // and the `optimizeDeps.exclude` option. externalPackages: true, diff --git a/packages/angular/build/src/builders/unit-test/tests/behavior/vitest-shared-chunk-init_spec.ts b/packages/angular/build/src/builders/unit-test/tests/behavior/vitest-shared-chunk-init_spec.ts new file mode 100644 index 000000000000..13870ba36eba --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/tests/behavior/vitest-shared-chunk-init_spec.ts @@ -0,0 +1,172 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { execute } from '../../index'; +import { + BASE_OPTIONS, + describeBuilder, + UNIT_TEST_BUILDER_INFO, + setupApplicationTarget, +} from '../setup'; + +describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { + describe('Behavior: "Vitest shared chunk initialization"', () => { + // Regression test for https://github.com/angular/angular-cli/issues/33728. + // + // Without `disableCodeSplitting`, esbuild hoists a module imported by more than one spec + // entry point into a shared chunk behind a lazy `__esm` initializer, and a class-field + // initializer in another chunk reads the exported value as `undefined` under the jsdom + // runner. All four trigger conditions are required and encoded below: + // 1. two spec entry points import the shared module (so it lands in a shared chunk); + // 2. a component in one entry reads the export during class-field initialization; + // 3. that component's spec file contains an `async` test callback (no `await` needed); + // 4. zone.js is in the polyfills (the `setupApplicationTarget` default), which downlevels + // async and makes esbuild emit the spec entry CommonJS-wrapped. + // + // NOTE: the failure this guards against is sensitive to inert content — adding a top-level + // side effect (even a `console.log`) to the shared or importing module below defused it + // during reduction. Mirror https://github.com/jonmarozick/ng-shared-chunk-repro when + // modifying these fixtures. + it('should provide shared-module exports to class-field initializers in async specs', async () => { + setupApplicationTarget(harness); + + harness.useTarget('test', { + ...BASE_OPTIONS, + }); + + // Keep the default project's spec deterministic; a third spec entry that does not touch + // the shared module does not affect the reproduction (verified in a fresh workspace). + await harness.writeFile( + 'src/app/app.component.spec.ts', + ` + import { describe, it, expect } from 'vitest'; + + describe('AppComponent placeholder', () => { + it('runs', () => { + expect(1 + 1).toBe(2); + }); + }); + `, + ); + + // The shared `const`. Reached from both spec entry points, so it is hoisted into a chunk + // shared between them. + await harness.writeFile( + 'src/environments/env-config.ts', + ` + export interface DealerConfig { + dealerId: string; + clientKey: string; + } + + export const DEALERS: DealerConfig[] = [ + { dealerId: 'dealer-one', clientKey: 'KEY-ONE' }, + { dealerId: 'dealer-two', clientKey: 'KEY-TWO' }, + { dealerId: 'dealer-three', clientKey: 'KEY-THREE' }, + { dealerId: 'dealer-four', clientKey: 'KEY-FOUR' }, + ]; + `, + ); + + // Reached by both spec entries, so it and env-config.ts land in the shared chunk. Reads the + // const inside a method — after module initialization — and is the passing control. + await harness.writeFile( + 'src/app/features/lead-generator/services/lead.service.ts', + ` + import { Injectable } from '@angular/core'; + + import { DEALERS } from '../../../../environments/env-config'; + + @Injectable({ providedIn: 'root' }) + export class LeadService { + resolveClientKey(dealerId: string, clientKey?: string): string { + return clientKey ?? DEALERS.find((d) => d.dealerId === dealerId)?.clientKey ?? ''; + } + } + `, + ); + + await harness.writeFile( + 'src/app/features/lead-generator/services/index.ts', + `export * from './lead.service';\n`, + ); + + // Spec entry point 1 — the second importer that causes the chunk to be shared at all. + await harness.writeFile( + 'src/app/features/lead-generator/services/lead.service.spec.ts', + ` + import { describe, it, expect } from 'vitest'; + import { TestBed } from '@angular/core/testing'; + + import { LeadService } from './lead.service'; + + describe('LeadService', () => { + it('reads DEALERS inside a method', () => { + TestBed.configureTestingModule({ providers: [LeadService] }); + + expect(TestBed.inject(LeadService).resolveClientKey('dealer-one')).toBe('KEY-ONE'); + }); + }); + `, + ); + + // In the other chunk; reads the shared export eagerly during class-field initialization. + await harness.writeFile( + 'src/app/features/lead-generator/lead-generator.container.ts', + ` + import { Component, inject } from '@angular/core'; + + import { LeadService } from './services'; + import { DEALERS, DealerConfig } from '../../../environments/env-config'; + + @Component({ + selector: 'app-lead-generator', + standalone: true, + template: '', + }) + export class LeadGeneratorContainer { + private readonly leadService = inject(LeadService); + + readonly dealers: DealerConfig[] = DEALERS; + readonly dealerOptions = this.dealers.map((d) => d.dealerId); + + hasService(): boolean { + return this.leadService != null; + } + } + `, + ); + + // Spec entry point 2 — the failing case without the fix. The `async` is load-bearing: + // zone.js makes the builder downlevel it, the spec then imports the `__async` helper, and + // esbuild emits this entry CommonJS-wrapped with the component module behind a lazy + // `__esm` initializer. No `await` is needed; a synchronous callback hides the defect. + await harness.writeFile( + 'src/app/features/lead-generator/lead-generator.container.spec.ts', + ` + import { describe, it, expect } from 'vitest'; + import { TestBed } from '@angular/core/testing'; + + import { LeadGeneratorContainer } from './lead-generator.container'; + + describe('LeadGeneratorContainer', () => { + it('reads DEALERS in a class-field initialiser', async () => { + const fixture = TestBed.createComponent(LeadGeneratorContainer); + + expect(fixture.componentInstance.dealers.length).toBe(4); + }); + }); + `, + ); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBeTrue(); + }); + }); +}); diff --git a/packages/angular/build/src/tools/esbuild/application-code-bundle.ts b/packages/angular/build/src/tools/esbuild/application-code-bundle.ts index 37ff846c7400..9f5b1ee5f1a2 100644 --- a/packages/angular/build/src/tools/esbuild/application-code-bundle.ts +++ b/packages/angular/build/src/tools/esbuild/application-code-bundle.ts @@ -71,6 +71,12 @@ export function createBrowserCodeBundleOptions( supported: getFeatureSupport(zoneless), }; + if (options.disableCodeSplitting) { + // Splitting emits shared chunks that are read across chunk boundaries as live ESM bindings, + // which the unit-test runners' module loading does not reliably preserve. + buildOptions.splitting = false; + } + buildOptions.plugins ??= []; buildOptions.plugins.push( createWasmPlugin({ allowAsync: zoneless, cache: loadCache }),