| undefined,
): {
typescriptContexts: BundlerContext[];
@@ -63,9 +63,10 @@ export function setupBundlerContexts(
target,
codeBundleCache,
stylesheetBundler,
- angularCompilation,
+ angularCompilationContext,
templateUpdates,
),
+ true,
),
);
@@ -75,12 +76,14 @@ export function setupBundlerContexts(
target,
codeBundleCache,
stylesheetBundler,
+ angularCompilationContext.createSecondaryContext(),
);
if (browserPolyfillBundleOptions) {
const browserPolyfillContext = new BundlerContext(
workspaceRoot,
watch,
browserPolyfillBundleOptions,
+ true,
);
if (typeof browserPolyfillBundleOptions === 'function') {
otherContexts.push(browserPolyfillContext);
@@ -94,7 +97,9 @@ export function setupBundlerContexts(
for (const initial of [true, false]) {
const bundleOptions = createGlobalStylesBundleOptions(options, target, initial);
if (bundleOptions) {
- otherContexts.push(new BundlerContext(workspaceRoot, watch, bundleOptions, () => initial));
+ otherContexts.push(
+ new BundlerContext(workspaceRoot, watch, bundleOptions, true, () => initial),
+ );
}
}
}
@@ -104,7 +109,9 @@ export function setupBundlerContexts(
for (const initial of [true, false]) {
const bundleOptions = createGlobalScriptsBundleOptions(options, target, initial);
if (bundleOptions) {
- otherContexts.push(new BundlerContext(workspaceRoot, watch, bundleOptions, () => initial));
+ otherContexts.push(
+ new BundlerContext(workspaceRoot, watch, bundleOptions, true, () => initial),
+ );
}
}
}
@@ -117,7 +124,14 @@ export function setupBundlerContexts(
new BundlerContext(
workspaceRoot,
watch,
- createServerMainCodeBundleOptions(options, nodeTargets, codeBundleCache, stylesheetBundler),
+ createServerMainCodeBundleOptions(
+ options,
+ nodeTargets,
+ codeBundleCache,
+ stylesheetBundler,
+ angularCompilationContext.createSecondaryContext(),
+ ),
+ true,
),
);
@@ -127,7 +141,14 @@ export function setupBundlerContexts(
new BundlerContext(
workspaceRoot,
watch,
- createSsrEntryCodeBundleOptions(options, nodeTargets, codeBundleCache, stylesheetBundler),
+ createSsrEntryCodeBundleOptions(
+ options,
+ nodeTargets,
+ codeBundleCache,
+ stylesheetBundler,
+ angularCompilationContext.createSecondaryContext(),
+ ),
+ true,
),
);
}
@@ -140,7 +161,9 @@ export function setupBundlerContexts(
);
if (serverPolyfillBundleOptions) {
- otherContexts.push(new BundlerContext(workspaceRoot, watch, serverPolyfillBundleOptions));
+ otherContexts.push(
+ new BundlerContext(workspaceRoot, watch, serverPolyfillBundleOptions, true),
+ );
}
}
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
new file mode 100644
index 000000000000..76ecbbc4329e
--- /dev/null
+++ b/packages/angular/build/src/builders/application/tests/behavior/chunk-optimization-server_spec.ts
@@ -0,0 +1,187 @@
+/**
+ * @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 { buildApplication } from '../../index';
+import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup';
+
+/**
+ * Fixture application with a server entry point and four lazy routes.
+ * Four lazy chunks exceed the default chunk optimization threshold (3),
+ * so the optimization pass runs without requiring the
+ * `NG_BUILD_OPTIMIZE_CHUNKS` environment variable (which is captured at
+ * module load time and cannot be toggled per spec).
+ *
+ * `shared.ts` is imported statically by both `main.ts` and two of the lazy
+ * components. esbuild emits such modules as a separate `chunk-*.js` shared
+ * chunk, while the chunk optimizer merges entry-reachable modules back into
+ * the main chunk. The absence of `chunk-*.js` files is therefore used as a
+ * signal that the optimization pass actually ran.
+ */
+const LAZY_ROUTE_NAMES = ['lazy-a', 'lazy-b', 'lazy-c', 'lazy-d'] as const;
+
+function lazyComponentSource(name: string, useShared: boolean): string {
+ const className = name.replace(/(^|-)(\w)/g, (_, __, c: string) => c.toUpperCase());
+
+ return `
+ import { Component } from '@angular/core';
+ ${useShared ? `import { sharedValue } from '../shared';` : ''}
+
+ @Component({
+ selector: 'app-${name}',
+ template: '${name} works! ${useShared ? '{{ shared }}' : ''}
',
+ })
+ export default class ${className}Component {
+ ${useShared ? `shared = sharedValue();` : ''}
+ }
+ `;
+}
+
+const serverLazyRoutesFiles: Record = {
+ 'src/shared.ts': `
+ export function sharedValue(): string {
+ return 'shared-' + Date.now().toString(36);
+ }
+ `,
+ 'src/app/app.routes.ts': `
+ import { Routes } from '@angular/router';
+
+ export const routes: Routes = [
+ ${LAZY_ROUTE_NAMES.map(
+ (name) => `{ path: '${name}', loadComponent: () => import('./${name}.component') },`,
+ ).join('\n ')}
+ ];
+ `,
+ ...Object.fromEntries(
+ LAZY_ROUTE_NAMES.map((name, index) => [
+ `src/app/${name}.component.ts`,
+ lazyComponentSource(name, index < 2),
+ ]),
+ ),
+ 'src/app/app.component.ts': `
+ import { Component } from '@angular/core';
+ import { RouterOutlet } from '@angular/router';
+ import { sharedValue } from '../shared';
+
+ @Component({
+ selector: 'app-root',
+ imports: [RouterOutlet],
+ template: '{{ shared }}
',
+ })
+ export class AppComponent {
+ shared = sharedValue();
+ }
+ `,
+ 'src/app/app.config.ts': `
+ import { ApplicationConfig } from '@angular/core';
+ import { provideRouter } from '@angular/router';
+ import { routes } from './app.routes';
+
+ export const appConfig: ApplicationConfig = {
+ providers: [provideRouter(routes)],
+ };
+ `,
+ 'src/main.ts': `
+ import { bootstrapApplication } from '@angular/platform-browser';
+ import { AppComponent } from './app/app.component';
+ import { appConfig } from './app/app.config';
+
+ bootstrapApplication(AppComponent, appConfig).catch((err) => console.error(err));
+ `,
+ 'src/main.server.ts': `
+ import { mergeApplicationConfig } from '@angular/core';
+ import { bootstrapApplication, BootstrapContext } from '@angular/platform-browser';
+ import { provideServerRendering } from '@angular/platform-server';
+ import { AppComponent } from './app/app.component';
+ import { appConfig } from './app/app.config';
+
+ const serverConfig = mergeApplicationConfig(appConfig, {
+ providers: [provideServerRendering()],
+ });
+
+ const bootstrap = (context: BootstrapContext) =>
+ bootstrapApplication(AppComponent, serverConfig, context);
+
+ export default bootstrap;
+ `,
+};
+
+describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => {
+ describe('Behavior: "Chunk optimization with a server entry point"', () => {
+ beforeEach(async () => {
+ await harness.modifyFile('src/tsconfig.app.json', (content) => {
+ const tsConfig = JSON.parse(content);
+ tsConfig.files ??= [];
+ tsConfig.files.push('main.server.ts');
+
+ return JSON.stringify(tsConfig);
+ });
+
+ await harness.writeFiles(serverLazyRoutesFiles);
+ });
+
+ it('generates a server manifest consistent with the optimized browser chunks', async () => {
+ harness.useTarget('build', {
+ ...BASE_OPTIONS,
+ server: 'src/main.server.ts',
+ ssr: true,
+ polyfills: ['zone.js'],
+ optimization: true,
+ // Name lazy chunks after their route entry points so that only shared
+ // chunks use the `chunk-` prefix, which the assertions below rely on.
+ namedChunks: true,
+ });
+
+ const { result } = await harness.executeOnce();
+ expect(result?.success).toBeTrue();
+
+ // The chunk optimizer merges entry-reachable shared modules back into the
+ // main chunk. A remaining `chunk-*.js` shared chunk indicates the
+ // optimization pass did not run and this test would be vacuous.
+ expect(harness.hasFileMatch('dist/browser', /^chunk-/)).toBeFalse();
+
+ const manifestContent = harness.readFile('dist/server/angular-app-manifest.mjs');
+ const mappingSource = /entryPointToBrowserMapping: (\{[\s\S]*?\n\})/.exec(manifestContent);
+ expect(mappingSource)
+ .withContext('entryPointToBrowserMapping should be present in the server manifest')
+ .not.toBeNull();
+
+ const mapping = JSON.parse(mappingSource![1]) as Record;
+
+ // Every lazy route entry point must retain a mapping entry after optimization.
+ for (const name of LAZY_ROUTE_NAMES) {
+ const key = Object.keys(mapping).find((entryPoint) =>
+ entryPoint.endsWith(`${name}.component.ts`),
+ );
+ expect(key)
+ .withContext(`mapping entry for lazy route '${name}' should exist`)
+ .toBeDefined();
+ }
+
+ // Every browser file referenced by the mapping must exist on disk.
+ for (const files of Object.values(mapping)) {
+ for (const file of files) {
+ expect(harness.hasFile(`dist/browser/${file}`))
+ .withContext(`mapped browser file '${file}' should exist`)
+ .toBeTrue();
+ }
+ }
+
+ // All scripts referenced by the index HTML must exist on disk.
+ const indexContent = harness.readFile('dist/browser/index.csr.html');
+ const scriptRefs = [
+ ...indexContent.matchAll(/<(?:script src|link rel="modulepreload" href)="([^"]+)"/g),
+ ].map((match) => match[1]);
+ expect(scriptRefs.length).toBeGreaterThan(0);
+ for (const file of scriptRefs) {
+ expect(harness.hasFile(`dist/browser/${file}`))
+ .withContext(`index.html referenced file '${file}' should exist`)
+ .toBeTrue();
+ }
+ });
+ });
+});
diff --git a/packages/angular/build/src/builders/application/tests/behavior/rebuild-global_styles_spec.ts b/packages/angular/build/src/builders/application/tests/behavior/rebuild-global_styles_spec.ts
index 22c4c32202bd..2de5c5f229ca 100644
--- a/packages/angular/build/src/builders/application/tests/behavior/rebuild-global_styles_spec.ts
+++ b/packages/angular/build/src/builders/application/tests/behavior/rebuild-global_styles_spec.ts
@@ -132,5 +132,119 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => {
{ outputLogsOnFailure: false },
);
});
+
+ it('rebuilds PostCSS stylesheet after error on rebuild from plugin dependency', async () => {
+ harness.useTarget('build', {
+ ...BASE_OPTIONS,
+ watch: true,
+ styles: ['src/styles.css'],
+ });
+
+ await harness.writeFile(
+ 'test-plugin.js',
+ `
+ const fs = require('fs');
+ const path = require('path');
+ module.exports = () => {
+ return {
+ postcssPlugin: 'test-plugin',
+ Once(root, { result }) {
+ const themePath = path.join(path.dirname(root.source.input.file), 'theme.json');
+ result.messages.push({
+ type: 'dependency',
+ file: themePath,
+ });
+ const data = fs.readFileSync(themePath, 'utf-8');
+ const json = JSON.parse(data);
+ root.append('body { color: ' + json.color + '; }');
+ },
+ };
+ };
+ module.exports.postcss = true;
+ `,
+ );
+ await harness.writeFile(
+ '.postcssrc.json',
+ JSON.stringify({
+ plugins: {
+ './test-plugin.js': {},
+ },
+ }),
+ );
+ await harness.writeFile('src/styles.css', '/* base */');
+ await harness.writeFile('src/theme.json', '{"color": "aqua"}');
+
+ await harness.executeWithCases(
+ [
+ async ({ result }) => {
+ expect(result?.success).toBe(true);
+ harness.expectFile('dist/browser/styles.css').content.toContain('color: aqua');
+ harness.expectFile('dist/browser/styles.css').content.not.toContain('color: blue');
+
+ await harness.writeFile('src/theme.json', 'invalid-json');
+ },
+ async ({ result }) => {
+ expect(result?.success).toBe(false);
+
+ await harness.writeFile('src/theme.json', '{"color": "blue"}');
+ },
+ ({ result }) => {
+ expect(result?.success).toBe(true);
+ harness.expectFile('dist/browser/styles.css').content.not.toContain('color: aqua');
+ harness.expectFile('dist/browser/styles.css').content.toContain('color: blue');
+ },
+ ],
+ { outputLogsOnFailure: false },
+ );
+ });
+
+ it('rebuilds PostCSS stylesheet after CSS syntax error on initial build from import', async () => {
+ harness.useTarget('build', {
+ ...BASE_OPTIONS,
+ watch: true,
+ styles: ['src/styles.css'],
+ });
+
+ await harness.writeFile(
+ 'noop-plugin.js',
+ `
+ module.exports = () => ({ postcssPlugin: 'noop-plugin' });
+ module.exports.postcss = true;
+ `,
+ );
+ await harness.writeFile(
+ '.postcssrc.json',
+ JSON.stringify({
+ plugins: {
+ './noop-plugin.js': {},
+ },
+ }),
+ );
+ await harness.writeFile('src/styles.css', "@import './a.css';");
+ await harness.writeFile('src/a.css', "a { ' }");
+
+ await harness.executeWithCases(
+ [
+ async ({ result }) => {
+ expect(result?.success).toBe(false);
+
+ await harness.writeFile('src/a.css', 'body { color: aqua; }');
+ },
+ async ({ result }) => {
+ expect(result?.success).toBe(true);
+ harness.expectFile('dist/browser/styles.css').content.toContain('color: aqua');
+ harness.expectFile('dist/browser/styles.css').content.not.toContain('color: blue');
+
+ await harness.writeFile('src/a.css', 'body { color: blue; }');
+ },
+ ({ result }) => {
+ expect(result?.success).toBe(true);
+ harness.expectFile('dist/browser/styles.css').content.not.toContain('color: aqua');
+ harness.expectFile('dist/browser/styles.css').content.toContain('color: blue');
+ },
+ ],
+ { outputLogsOnFailure: false },
+ );
+ });
});
});
diff --git a/packages/angular/build/src/builders/application/tests/behavior/top-level-await-error_spec.ts b/packages/angular/build/src/builders/application/tests/behavior/top-level-await-error_spec.ts
new file mode 100644
index 000000000000..b0220529ee28
--- /dev/null
+++ b/packages/angular/build/src/builders/application/tests/behavior/top-level-await-error_spec.ts
@@ -0,0 +1,67 @@
+/**
+ * @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 { buildApplication } from '../../index';
+import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup';
+
+describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => {
+ describe('Behavior: "Top-level await error message"', () => {
+ it('should show a Zone.js-specific error when top-level await is used with Zone.js', async () => {
+ await harness.writeFile(
+ 'src/main.ts',
+ `
+ // The export makes this file a module, which is required for top-level await.
+ export const value = await Promise.resolve('test');
+ console.log(value);
+ `,
+ );
+
+ harness.useTarget('build', {
+ ...BASE_OPTIONS,
+ polyfills: ['zone.js'],
+ });
+
+ const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false });
+ expect(result?.success).toBeFalse();
+ expect(logs).toContain(
+ jasmine.objectContaining({
+ message: jasmine.stringMatching(
+ 'Top-level await is not supported in applications that use Zone.js',
+ ),
+ }),
+ );
+ });
+
+ it('should not show a Zone.js-specific error when top-level await is used without Zone.js', async () => {
+ await harness.writeFile(
+ 'src/main.ts',
+ `
+ // The export makes this file a module, which is required for top-level await.
+ export const value = await Promise.resolve('test');
+ console.log(value);
+ `,
+ );
+
+ harness.useTarget('build', {
+ ...BASE_OPTIONS,
+ polyfills: [],
+ });
+
+ const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false });
+ expect(result?.success).toBeTrue();
+ expect(logs).not.toContain(
+ jasmine.objectContaining({
+ level: 'error',
+ message: jasmine.stringContaining(
+ 'Top-level await is not supported in applications that use Zone.js',
+ ),
+ }),
+ );
+ });
+ });
+});
diff --git a/packages/angular/build/src/builders/application/tests/options/external-dependencies_spec.ts b/packages/angular/build/src/builders/application/tests/options/external-dependencies_spec.ts
index deb55e172109..bdb8f9428f56 100644
--- a/packages/angular/build/src/builders/application/tests/options/external-dependencies_spec.ts
+++ b/packages/angular/build/src/builders/application/tests/options/external-dependencies_spec.ts
@@ -74,5 +74,33 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => {
// If not externalized, build will fail with a Node.js platform builtin error
expect(result?.success).toBeTrue();
});
+
+ it('should not externalize builder-injected i18n locale-data imports when @angular/common is external', async () => {
+ harness.useProject('test', {
+ root: '.',
+ sourceRoot: 'src',
+ cli: {
+ cache: {
+ enabled: false,
+ },
+ },
+ i18n: {
+ sourceLocale: 'fr',
+ },
+ });
+
+ harness.useTarget('build', {
+ ...BASE_OPTIONS,
+ externalDependencies: ['@angular/common'],
+ });
+
+ const { result } = await harness.executeOnce();
+ expect(result?.success).toBeTrue();
+
+ harness.expectFile('dist/browser/polyfills.js').toExist();
+ harness
+ .expectFile('dist/browser/polyfills.js')
+ .content.not.toMatch(/['"]@angular\/common\/locales\/global\/fr['"]/);
+ });
});
});
diff --git a/packages/angular/build/src/builders/application/tests/options/extract-licenses_spec.ts b/packages/angular/build/src/builders/application/tests/options/extract-licenses_spec.ts
index 402200a27f9d..2c495e703a7b 100644
--- a/packages/angular/build/src/builders/application/tests/options/extract-licenses_spec.ts
+++ b/packages/angular/build/src/builders/application/tests/options/extract-licenses_spec.ts
@@ -55,5 +55,96 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => {
harness.expectFile('dist/3rdpartylicenses.txt').content.toContain('MIT');
harness.expectFile('dist/browser/en-US/main.js').toExist();
});
+
+ it(`should extract license from a package with a lowercase 'license' file`, async () => {
+ await harness.writeFile(
+ 'node_modules/test-package-a/package.json',
+ JSON.stringify({
+ name: 'test-package-a',
+ version: '1.0.0',
+ main: 'index.js',
+ license: 'MIT',
+ }),
+ );
+ await harness.writeFile(
+ 'node_modules/test-package-a/index.js',
+ 'console.log("test-package-a");',
+ );
+ await harness.writeFile('node_modules/test-package-a/license', 'TEST_LOWERCASE_LICENSE_TEXT');
+ await harness.appendToFile('src/main.ts', "\nimport 'test-package-a';\n");
+
+ harness.useTarget('build', {
+ ...BASE_OPTIONS,
+ extractLicenses: true,
+ });
+
+ const { result } = await harness.executeOnce();
+ expect(result?.success).toBeTrue();
+ harness
+ .expectFile('dist/3rdpartylicenses.txt')
+ .content.toContain('TEST_LOWERCASE_LICENSE_TEXT');
+ });
+
+ it(`should extract license from a package with an alternative license file name (e.g., 'MIT-LICENCE.txt')`, async () => {
+ await harness.writeFile(
+ 'node_modules/test-package-b/package.json',
+ JSON.stringify({
+ name: 'test-package-b',
+ version: '1.0.0',
+ main: 'index.js',
+ license: 'MIT',
+ }),
+ );
+ await harness.writeFile(
+ 'node_modules/test-package-b/index.js',
+ 'console.log("test-package-b");',
+ );
+ await harness.writeFile(
+ 'node_modules/test-package-b/MIT-LICENCE.txt',
+ 'TEST_ALTERNATIVE_LICENSE_TEXT',
+ );
+ await harness.appendToFile('src/main.ts', "\nimport 'test-package-b';\n");
+
+ harness.useTarget('build', {
+ ...BASE_OPTIONS,
+ extractLicenses: true,
+ });
+
+ const { result } = await harness.executeOnce();
+ expect(result?.success).toBeTrue();
+ harness
+ .expectFile('dist/3rdpartylicenses.txt')
+ .content.toContain('TEST_ALTERNATIVE_LICENSE_TEXT');
+ });
+
+ it(`should extract license from a package with a custom license file specified in package.json`, async () => {
+ await harness.writeFile(
+ 'node_modules/test-package-c/package.json',
+ JSON.stringify({
+ name: 'test-package-c',
+ version: '1.0.0',
+ main: 'index.js',
+ license: 'SEE LICENSE IN custom-license.md',
+ }),
+ );
+ await harness.writeFile(
+ 'node_modules/test-package-c/index.js',
+ 'console.log("test-package-c");',
+ );
+ await harness.writeFile(
+ 'node_modules/test-package-c/custom-license.md',
+ 'TEST_CUSTOM_LICENSE_TEXT',
+ );
+ await harness.appendToFile('src/main.ts', "\nimport 'test-package-c';\n");
+
+ harness.useTarget('build', {
+ ...BASE_OPTIONS,
+ extractLicenses: true,
+ });
+
+ const { result } = await harness.executeOnce();
+ expect(result?.success).toBeTrue();
+ harness.expectFile('dist/3rdpartylicenses.txt').content.toContain('TEST_CUSTOM_LICENSE_TEXT');
+ });
});
});
diff --git a/packages/angular/build/src/builders/application/tests/options/index_spec.ts b/packages/angular/build/src/builders/application/tests/options/index_spec.ts
index 11228658bbce..b53467caaec6 100644
--- a/packages/angular/build/src/builders/application/tests/options/index_spec.ts
+++ b/packages/angular/build/src/builders/application/tests/options/index_spec.ts
@@ -79,16 +79,17 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => {
harness.expectFile('dist/browser/index.html').content.toContain('TEST_123');
});
- // TODO: Build needs to be fixed to not throw an unhandled exception for this case
- xit('should fail build when a string path to non-existent file', async () => {
+ it('should fail build and terminate cleanly when a string path to non-existent file is provided', async () => {
harness.useTarget('build', {
...BASE_OPTIONS,
index: 'src/not-here.html',
});
- const { result } = await harness.executeOnce({ outputLogsOnFailure: false });
+ const { result, error } = await harness.executeOnce({ outputLogsOnException: false });
- expect(result?.success).toBe(false);
+ expect(result).toBeUndefined();
+ expect(error).toEqual(jasmine.any(Error));
+ expect(error?.message).toMatch(/Failed to read index HTML file/i);
harness.expectFile('dist/browser/index.html').toNotExist();
});
diff --git a/packages/angular/build/src/builders/application/tests/options/stats-json_spec.ts b/packages/angular/build/src/builders/application/tests/options/stats-json_spec.ts
new file mode 100644
index 000000000000..f38ae4996d16
--- /dev/null
+++ b/packages/angular/build/src/builders/application/tests/options/stats-json_spec.ts
@@ -0,0 +1,103 @@
+/**
+ * @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 { buildApplication } from '../../index';
+import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup';
+
+describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => {
+ describe('Option: "statsJson"', () => {
+ it('generates only browser stats file containing valid metafile data when true', async () => {
+ harness.useTarget('build', {
+ ...BASE_OPTIONS,
+ statsJson: true,
+ });
+
+ const { result } = await harness.executeOnce();
+ expect(result?.success).toBeTrue();
+
+ harness.expectFile('dist/browser-stats.json').toExist();
+ harness.expectFile('dist/server-stats.json').toNotExist();
+
+ const browserStats = JSON.parse(harness.readFile('dist/browser-stats.json'));
+ expect(browserStats.inputs).toBeDefined();
+ expect(browserStats.outputs).toBeDefined();
+ expect(Object.keys(browserStats.outputs).length).toBeGreaterThan(0);
+ });
+
+ it('does not generate stats files when false', async () => {
+ harness.useTarget('build', {
+ ...BASE_OPTIONS,
+ statsJson: false,
+ });
+
+ const { result } = await harness.executeOnce();
+ expect(result?.success).toBeTrue();
+ harness.expectFile('dist/browser-stats.json').toNotExist();
+ harness.expectFile('dist/server-stats.json').toNotExist();
+ });
+
+ it('does not generate stats files when not set', async () => {
+ harness.useTarget('build', {
+ ...BASE_OPTIONS,
+ });
+
+ const { result } = await harness.executeOnce();
+ expect(result?.success).toBeTrue();
+ harness.expectFile('dist/browser-stats.json').toNotExist();
+ harness.expectFile('dist/server-stats.json').toNotExist();
+ });
+
+ describe('server build', () => {
+ beforeEach(async () => {
+ await harness.modifyFile('src/tsconfig.app.json', (content) => {
+ const tsConfig = JSON.parse(content);
+ tsConfig.files ??= [];
+ tsConfig.files.push('main.server.ts');
+
+ return JSON.stringify(tsConfig);
+ });
+ });
+
+ it('generates separated browser and server stats files for an SSR build', async () => {
+ harness.useTarget('build', {
+ ...BASE_OPTIONS,
+ server: 'src/main.server.ts',
+ ssr: true,
+ statsJson: true,
+ });
+
+ const { result } = await harness.executeOnce();
+ expect(result?.success).toBeTrue();
+
+ harness.expectFile('dist/browser-stats.json').toExist();
+ harness.expectFile('dist/server-stats.json').toExist();
+
+ const browserStats = JSON.parse(harness.readFile('dist/browser-stats.json'));
+ const serverStats = JSON.parse(harness.readFile('dist/server-stats.json'));
+
+ const browserPaths = new Set(Object.keys(browserStats.outputs));
+ const serverPaths = new Set(Object.keys(serverStats.outputs));
+
+ expect(serverPaths.size).toBeGreaterThan(0);
+ expect(browserPaths.size).toBeGreaterThan(0);
+
+ for (const path of serverPaths) {
+ expect(browserPaths.has(path))
+ .withContext(`Server output '${path}' should not appear in browser-stats.json`)
+ .toBeFalse();
+ }
+
+ for (const path of browserPaths) {
+ expect(serverPaths.has(path))
+ .withContext(`Browser output '${path}' should not appear in server-stats.json`)
+ .toBeFalse();
+ }
+ });
+ });
+ });
+});
diff --git a/packages/angular/build/src/builders/application/tests/options/subresource-integrity_spec.ts b/packages/angular/build/src/builders/application/tests/options/subresource-integrity_spec.ts
index 5153045dba73..ca9212b0d3e3 100644
--- a/packages/angular/build/src/builders/application/tests/options/subresource-integrity_spec.ts
+++ b/packages/angular/build/src/builders/application/tests/options/subresource-integrity_spec.ts
@@ -83,6 +83,83 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => {
expectNoLog(logs, /subresource-integrity/);
});
+ it(`embeds an ECMA-426 debugId in JS and source map and the integrity matches`, async () => {
+ harness.useTarget('build', {
+ ...BASE_OPTIONS,
+ subresourceIntegrity: true,
+ sourceMap: { scripts: true },
+ });
+
+ const { result } = await harness.executeOnce();
+ expect(result?.success).toBe(true);
+
+ const distDir = workspacePath('dist/browser');
+ const allEntries = readdirSync(distDir);
+ const jsFiles = allEntries.filter(
+ (f) => f.endsWith('.js') && allEntries.includes(`${f}.map`),
+ );
+ expect(jsFiles.length).toBeGreaterThan(0);
+
+ const debugIdRe = /\/\/# debugId=([^\r\n]*)/;
+ const indexHtml = harness.readFile('dist/browser/index.html');
+ const importmapMatch = indexHtml.match(/'),
+ });
+
+ expect(errors).toEqual([]);
+ expect(warnings).toEqual([]);
+ expect(findFile(outputFiles, 'main.js').text).toBe(
+ 'export const greeting = "Bonjour \\"mon ami\\" \\\\ \' \\n ";\n',
+ );
+ });
+
+ it('inlines translations containing placeholders', async () => {
+ const source = 'export const welcome = (name) => $localize`:@@welcome:Hello ${name}!`;\n';
+ const { outputFiles, errors, warnings } = await createInliner([
+ browserFile('main.js', source),
+ ]).inlineForLocale('fr', {
+ welcome: {
+ messageParts: ['Bonjour ', ' !'],
+ placeholderNames: ['PH'],
+ text: 'Bonjour {$PH} !',
+ },
+ });
+
+ expect(errors).toEqual([]);
+ expect(warnings).toEqual([]);
+ expect(findFile(outputFiles, 'main.js').text).toBe(
+ 'export const welcome = (name) => `Bonjour ${name} !`;\n',
+ );
+ });
+
+ it('inlines multiple localize calls within the same file', async () => {
+ const source =
+ 'export const a = $localize`:@@greeting:Hello`;\nexport const b = $localize`:@@farewell:Goodbye`;\n';
+ const { outputFiles, errors, warnings } = await createInliner([
+ browserFile('main.js', source),
+ ]).inlineForLocale('fr', {
+ greeting: translationFor('Bonjour'),
+ farewell: translationFor('Au revoir'),
+ });
+
+ expect(errors).toEqual([]);
+ expect(warnings).toEqual([]);
+ expect(findFile(outputFiles, 'main.js').text).toBe(
+ 'export const a = "Bonjour";\nexport const b = "Au revoir";\n',
+ );
+ });
+
+ it('inlines translations across multiple files using multiple worker threads in parallel', async () => {
+ inliner = new I18nInliner(
+ {
+ missingTranslation: 'warning',
+ outputFiles: [
+ browserFile('main.js', GREETING_SOURCE),
+ browserFile('chunk1.js', GREETING_SOURCE),
+ browserFile('chunk2.js', GREETING_SOURCE),
+ browserFile('chunk3.js', GREETING_SOURCE),
+ ],
+ },
+ 4,
+ );
+
+ const { outputFiles, errors, warnings } = await inliner.inlineForLocale('fr', {
+ greeting: translationFor('Bonjour'),
+ });
+
+ expect(errors).toEqual([]);
+ expect(warnings).toEqual([]);
+ for (const name of ['main.js', 'chunk1.js', 'chunk2.js', 'chunk3.js']) {
+ expect(findFile(outputFiles, name).text).toBe('export const greeting = "Bonjour";\n');
+ }
+ });
+
+ it('leaves files without localize calls unmodified', async () => {
+ const { outputFiles } = await createInliner([
+ browserFile('main.js', GREETING_SOURCE),
+ browserFile('other.js', 'export const answer = 42;\n'),
+ ]).inlineForLocale('fr', { greeting: translationFor('Bonjour') });
+
+ expect(findFile(outputFiles, 'other.js').text).toBe('export const answer = 42;\n');
+ });
+
+ it('inlines nested $localize calls in post-order', async () => {
+ const source =
+ 'export const msg = $localize`:@@outer:You selected ${$localize`:@@inner:Apple`} for delivery.`;\n';
+ const { outputFiles, errors, warnings } = await createInliner([
+ browserFile('main.js', source),
+ ]).inlineForLocale('fr', {
+ inner: translationFor('Pomme'),
+ outer: {
+ messageParts: ['Vous avez sélectionné ', ' pour la livraison.'],
+ placeholderNames: ['PH'],
+ text: 'Vous avez sélectionné {$PH} pour la livraison.',
+ },
+ });
+
+ expect(errors).toEqual([]);
+ expect(warnings).toEqual([]);
+ expect(findFile(outputFiles, 'main.js').text).toBe(
+ 'export const msg = `Vous avez sélectionné ${"Pomme"} pour la livraison.`;\n',
+ );
+ });
+
+ it('reports an error diagnostic when a $localize template has a malformed escape sequence', async () => {
+ const source = 'export const msg = $localize`:@@id:\\unicode:`;\n';
+ const { errors } = await createInliner([browserFile('main.js', source)]).inlineForLocale(
+ 'fr',
+ {},
+ );
+
+ expect(errors).toEqual([
+ 'Malformed escape sequence in $localize template literal in file "main.js".',
+ ]);
+ });
+
+ it('inlines the translations of a locale when translationIntegrity is provided', async () => {
+ const { outputFiles, errors, warnings } = await createInliner([
+ browserFile('main.js', GREETING_SOURCE),
+ ]).inlineForLocale('fr', { greeting: translationFor('Bonjour') }, 'sha256-test-integrity');
+
+ expect(errors).toEqual([]);
+ expect(warnings).toEqual([]);
+ expect(findFile(outputFiles, 'main.js').text).toContain('"Bonjour"');
+ expect(findFile(outputFiles, 'main.js').text).not.toContain('$localize');
+ });
+
+ it('inlines the translations of a locale when localizeVersion is configured in options', async () => {
+ inliner = new I18nInliner(
+ {
+ missingTranslation: 'warning',
+ outputFiles: [browserFile('main.js', GREETING_SOURCE)],
+ localizeVersion: '20.2.0',
+ },
+ 1,
+ );
+
+ const { outputFiles, errors, warnings } = await inliner.inlineForLocale(
+ 'fr',
+ { greeting: translationFor('Bonjour') },
+ 'sha256-test-integrity',
+ );
+
+ expect(errors).toEqual([]);
+ expect(warnings).toEqual([]);
+ expect(findFile(outputFiles, 'main.js').text).toContain('"Bonjour"');
+ expect(findFile(outputFiles, 'main.js').text).not.toContain('$localize');
+ });
+});
diff --git a/packages/angular/build/src/tools/esbuild/i18n-locale-plugin.ts b/packages/angular/build/src/tools/esbuild/i18n-locale-plugin.ts
index ae94b62ca16d..603c65677b00 100644
--- a/packages/angular/build/src/tools/esbuild/i18n-locale-plugin.ts
+++ b/packages/angular/build/src/tools/esbuild/i18n-locale-plugin.ts
@@ -7,7 +7,7 @@
*/
import type { Plugin, ResolveResult } from 'esbuild';
-import { createRequire } from 'node:module';
+import { createProjectResolver } from '../../utils/resolve-project';
/**
* The internal namespace used by generated locale import statements and Angular locale data plugin.
@@ -50,7 +50,7 @@ export function createAngularLocaleDataPlugin(): Plugin {
}
let exact = true;
- let localeRequire: NodeJS.Require | undefined;
+ let projectResolve: ((packageName: string) => string) | undefined;
while (partialLocaleTag) {
// Angular embeds the `en`/`en-US` locale into the framework and it does not need to be included again here.
// The onLoad hook below for the locale data namespace has an `empty` loader that will prevent inclusion.
@@ -73,10 +73,10 @@ export function createAngularLocaleDataPlugin(): Plugin {
let result: ResolveResult | undefined;
const { packages, absWorkingDir } = build.initialOptions;
if (packages === 'external' && absWorkingDir) {
- localeRequire ??= createRequire(absWorkingDir + '/');
+ projectResolve ??= createProjectResolver(absWorkingDir);
try {
- localeRequire.resolve(potentialPath);
+ projectResolve(potentialPath);
result = {
errors: [],
@@ -94,6 +94,17 @@ export function createAngularLocaleDataPlugin(): Plugin {
kind: 'import-statement',
resolveDir: absWorkingDir,
});
+ if (result && result.external && absWorkingDir) {
+ projectResolve ??= createProjectResolver(absWorkingDir);
+ try {
+ const resolvedPath = projectResolve(potentialPath);
+ result = {
+ ...result,
+ external: false,
+ path: resolvedPath,
+ };
+ } catch {}
+ }
}
if (result?.path) {
diff --git a/packages/angular/build/src/tools/esbuild/javascript-transformer-worker.ts b/packages/angular/build/src/tools/esbuild/javascript-transformer-worker.ts
index dfb7cfb2087f..f2ec7eccce6d 100644
--- a/packages/angular/build/src/tools/esbuild/javascript-transformer-worker.ts
+++ b/packages/angular/build/src/tools/esbuild/javascript-transformer-worker.ts
@@ -6,192 +6,247 @@
* found in the LICENSE file at https://angular.dev/license
*/
+import remapping, { type DecodedSourceMap, type EncodedSourceMap } from '@ampproject/remapping';
import { type PluginItem, transformAsync } from '@babel/core';
-import fs from 'node:fs';
import { createRequire } from 'node:module';
-import path from 'node:path';
+import { workerData } from 'node:worker_threads';
import Piscina from 'piscina';
+import { useBabelLinker } from '../../utils/environment-options.js';
+import {
+ findTrailingSourceMapComment,
+ isTrailingSourceMapComment,
+ loadInputSourceMap,
+ loadInputSourceMapFromUrl,
+ removeSourceMappingURL,
+} from '../../utils/source-map';
+import { transform as transformWithOxc } from '../oxc/oxc-transform.js';
+import type { JavaScriptTransformerOptions } from './javascript-transformer';
interface JavaScriptTransformRequest {
filename: string;
data: string | Uint8Array;
- sourcemap: boolean;
- thirdPartySourcemaps: boolean;
- advancedOptimizations: boolean;
skipLinker?: boolean;
sideEffects?: boolean;
- jit: boolean;
instrumentForCoverage?: boolean;
}
+interface TransformOptions extends Omit {
+ inputSourceMap?: EncodedSourceMap;
+ isAlreadyStripped?: boolean;
+}
+
+const {
+ sourcemap = false,
+ thirdPartySourcemaps = false,
+ advancedOptimizations = false,
+ jit = false,
+} = (workerData || {}) as Partial;
+
const textDecoder = new TextDecoder();
const textEncoder = new TextEncoder();
-/**
- * The function name prefix for all Angular partial compilation functions.
- * Used to determine if linking of a JavaScript file is required.
- * If any additional declarations are added or otherwise changed in the linker,
- * the names MUST begin with this prefix.
- */
-const LINKER_DECLARATION_PREFIX = 'ɵɵngDeclare';
+async function instrumentCoverage(
+ filename: string,
+ data: string,
+ useInputSourcemap: boolean,
+): Promise<{ code: string; map?: EncodedSourceMap }> {
+ try {
+ let resolvedPath = 'istanbul-lib-instrument';
+ try {
+ const requireFn = createRequire(filename);
+ resolvedPath = requireFn.resolve('istanbul-lib-instrument');
+ } catch {
+ // Fallback to pool worker import traversal
+ }
+
+ const { createInstrumenter } = (await import(
+ resolvedPath
+ )) as typeof import('istanbul-lib-instrument');
+ const instrumenter = createInstrumenter({
+ produceSourceMap: useInputSourcemap,
+ esModules: true,
+ });
+
+ const inputSourceMap = useInputSourcemap ? loadInputSourceMap(filename, data) : undefined;
+ const instrumentedCode = instrumenter.instrumentSync(
+ data,
+ filename,
+ inputSourceMap as Parameters[2],
+ );
+ const lastMap = useInputSourcemap
+ ? (instrumenter.lastSourceMap() as EncodedSourceMap)
+ : undefined;
+
+ return {
+ code: instrumentedCode,
+ map: lastMap ?? undefined,
+ };
+ } catch (error) {
+ throw new Error(
+ `The 'istanbul-lib-instrument' package is required for code coverage but was not found. Please install the package.`,
+ { cause: error },
+ );
+ }
+}
export default async function transformJavaScript(
request: JavaScriptTransformRequest,
): Promise {
const { filename, data, ...options } = request;
- const textData = typeof data === 'string' ? data : textDecoder.decode(data);
- const transformedData = await transformWithBabel(filename, textData, options);
+ const useInputSourcemap =
+ sourcemap && (!!thirdPartySourcemaps || !/[\\/]node_modules[\\/]/.test(filename));
+
+ let textData: string;
+ let inputSourceMap: EncodedSourceMap | undefined;
+ let isAlreadyStripped = false;
+
+ if (typeof data !== 'string') {
+ const trailing = findTrailingSourceMapComment(data);
+ if (trailing === null) {
+ // 0 comments: fast path, no sourcemap to load or strip
+ textData = textDecoder.decode(data);
+ isAlreadyStripped = true;
+ } else if (trailing !== undefined) {
+ if (useInputSourcemap) {
+ inputSourceMap = loadInputSourceMapFromUrl(filename, trailing.urlLine);
+ if (inputSourceMap !== undefined) {
+ // Valid trailing sourcemap comment confirmed: safe to slice code buffer for transformation passes.
+ // Note: If no passes modify the code, the untouched original `data` buffer is returned below.
+ textData = textDecoder.decode(trailing.code);
+ isAlreadyStripped = true;
+ } else {
+ // Not a valid trailing sourcemap (e.g. inside template literal): fallback to full decode
+ textData = textDecoder.decode(data);
+ }
+ } else if (isTrailingSourceMapComment(trailing.urlLine)) {
+ // Valid trailing sourcemap comment confirmed: safe to slice code buffer
+ textData = textDecoder.decode(trailing.code);
+ isAlreadyStripped = true;
+ } else {
+ // Fallback to full decode and state-machine stripping
+ textData = textDecoder.decode(data);
+ }
+ } else {
+ // Multiple comments or comment not at line start: fall back to full decode and string parser
+ textData = textDecoder.decode(data);
+ }
+ } else {
+ textData = data;
+ }
+
+ const transformedData = await transformJavaScriptImpl(filename, textData, {
+ ...options,
+ inputSourceMap,
+ isAlreadyStripped,
+ });
+
+ // If no transformations modified the code, return the original untouched data buffer via `move`.
+ // This preserves any original trailing sourcemap comment and avoids re-encoding.
+ if (transformedData === textData && typeof data !== 'string') {
+ return Piscina.move(data);
+ }
- // Transfer the data via `move` instead of cloning
return Piscina.move(textEncoder.encode(transformedData));
}
-/**
- * Cached instance of the compiler-cli linker's createEs2015LinkerPlugin function.
- */
-let linkerPluginCreator:
- | typeof import('@angular/compiler-cli/linker/babel').createEs2015LinkerPlugin
- | undefined;
-
-async function transformWithBabel(
+async function transformJavaScriptImpl(
filename: string,
data: string,
- options: Omit,
+ options: TransformOptions,
): Promise {
- const shouldLink = !options.skipLinker && (await requiresLinking(filename, data));
+ const shouldLink = !options.skipLinker;
const useInputSourcemap =
- options.sourcemap &&
- (!!options.thirdPartySourcemaps || !/[\\/]node_modules[\\/]/.test(filename));
+ sourcemap && (!!thirdPartySourcemaps || !/[\\/]node_modules[\\/]/.test(filename));
- const plugins: PluginItem[] = [];
+ let code = data;
+ const maps: (DecodedSourceMap | EncodedSourceMap)[] = [];
+ let coverageMap: EncodedSourceMap | undefined;
if (options.instrumentForCoverage) {
- try {
- let resolvedPath = 'istanbul-lib-instrument';
- try {
- const requireFn = createRequire(filename);
- resolvedPath = requireFn.resolve('istanbul-lib-instrument');
- } catch {
- // Fallback to pool worker import traversal
- }
-
- const istanbul = await import(resolvedPath);
- const programVisitor = istanbul.programVisitor ?? istanbul.default?.programVisitor;
-
- if (!programVisitor) {
- throw new Error('programVisitor is not available in istanbul-lib-instrument.');
- }
-
- const { default: coveragePluginFactory } =
- await import('../babel/plugins/add-code-coverage.js');
- plugins.push(coveragePluginFactory(programVisitor));
- } catch (error) {
- throw new Error(
- `The 'istanbul-lib-instrument' package is required for code coverage but was not found. Please install the package.`,
- { cause: error },
- );
- }
+ const result = await instrumentCoverage(filename, code, useInputSourcemap);
+ code = result.code;
+ coverageMap = result.map;
}
- if (shouldLink) {
- // Lazy load the linker plugin only when linking is required
- const linkerPlugin = await createLinkerPlugin(options);
- plugins.push(linkerPlugin);
+ if (shouldLink && useBabelLinker) {
+ const { createEs2015LinkerPlugin } = await import('@angular/compiler-cli/linker/babel');
+ const { ConsoleLogger, LogLevel } = await import('@angular/compiler-cli');
+
+ const result = await transformAsync(code, {
+ filename,
+ inputSourceMap: false,
+ sourceMaps: !!useInputSourcemap,
+ compact: false,
+ configFile: false,
+ babelrc: false,
+ browserslistConfigFile: false,
+ plugins: [
+ createEs2015LinkerPlugin({
+ fileSystem: {
+ exists: () => false,
+ readFile: () => '',
+ resolve: (...paths: string[]) => paths.join('/'),
+ dirname: (path: string) => path.split('/').slice(0, -1).join('/'),
+ relative: (_from: string, to: string) => to,
+ } as never,
+ logger: new ConsoleLogger(LogLevel.info),
+ linkerJitMode: jit,
+ // This is a workaround until https://github.com/angular/angular/issues/42769 is fixed.
+ sourceMapping: false,
+ }) as PluginItem,
+ ],
+ });
+
+ code = result?.code ?? code;
+ if (result?.map) {
+ maps.push(result.map as EncodedSourceMap);
+ }
}
- if (options.advancedOptimizations) {
- const { adjustStaticMembers, adjustTypeScriptEnums, elideAngularMetadata, markTopLevelPure } =
- await import('../babel/plugins');
-
+ // Run Oxc linking and/or advanced optimizations in a single unified AST traversal pass
+ const oxcLink = shouldLink && !useBabelLinker;
+ if (oxcLink || advancedOptimizations) {
const sideEffectFree = options.sideEffects === false;
const safeAngularPackage =
sideEffectFree && /[\\/]node_modules[\\/]@angular[\\/]/.test(filename);
-
- plugins.push(
- [markTopLevelPure, { topLevelSafeMode: !safeAngularPackage }],
- elideAngularMetadata,
- adjustTypeScriptEnums,
- [adjustStaticMembers, { wrapDecorators: sideEffectFree }],
- );
- }
-
- // If no additional transformations are needed, return the data directly
- if (plugins.length === 0) {
- // Strip sourcemaps if they should not be used
- return useInputSourcemap ? data : data.replace(/^\/\/# sourceMappingURL=[^\r\n]*/gm, '');
+ const topLevelSafeMode = !safeAngularPackage;
+
+ const result = transformWithOxc(filename, code, {
+ link: oxcLink,
+ jit,
+ advancedOptimizations,
+ sourcemap: useInputSourcemap,
+ sideEffects: options.sideEffects,
+ topLevelSafeMode,
+ });
+ code = result.code;
+ if (result.map) {
+ maps.push(result.map);
+ }
}
- const result = await transformAsync(data, {
- filename,
- inputSourceMap: (useInputSourcemap ? undefined : false) as undefined,
- sourceMaps: useInputSourcemap ? 'inline' : false,
- compact: false,
- configFile: false,
- babelrc: false,
- browserslistConfigFile: false,
- plugins,
- });
-
- const outputCode = result?.code ?? data;
+ if (useInputSourcemap) {
+ const baseMap = coverageMap ?? options.inputSourceMap ?? loadInputSourceMap(filename, data);
+ if (maps.length > 0 || coverageMap) {
+ if (!options.isAlreadyStripped) {
+ code = removeSourceMappingURL(code);
+ }
+ const remappingChain: (DecodedSourceMap | EncodedSourceMap)[] = maps.reverse();
+ if (baseMap) {
+ remappingChain.push(baseMap);
+ }
- // Strip sourcemaps if they should not be used.
- // Babel will keep the original comments even if sourcemaps are disabled.
- return useInputSourcemap
- ? outputCode
- : outputCode.replace(/^\/\/# sourceMappingURL=[^\r\n]*/gm, '');
-}
+ if (remappingChain.length > 0) {
+ const finalMap = remapping(remappingChain, () => null).toString();
+ const base64Map = Buffer.from(finalMap).toString('base64');
+ code += `\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,${base64Map}`;
+ }
+ }
-async function requiresLinking(path: string, source: string): Promise {
- // @angular/core and @angular/compiler will cause false positives
- // Also, TypeScript files do not require linking
- if (/[\\/]@angular[\\/](?:compiler|core)|\.tsx?$/.test(path)) {
- return false;
+ return code;
}
- // Check if the source code includes one of the declaration functions.
- // There is a low chance of a false positive but the names are fairly unique
- // and the result would be an unnecessary no-op additional plugin pass.
- return source.includes(LINKER_DECLARATION_PREFIX);
-}
-
-async function createLinkerPlugin(options: Omit) {
- linkerPluginCreator ??= (await import('@angular/compiler-cli/linker/babel'))
- .createEs2015LinkerPlugin;
-
- const linkerPlugin = linkerPluginCreator({
- linkerJitMode: options.jit,
- // This is a workaround until https://github.com/angular/angular/issues/42769 is fixed.
- sourceMapping: false,
- logger: {
- level: 1, // Info level
- debug(...args: string[]) {
- // eslint-disable-next-line no-console
- console.debug(args);
- },
- info(...args: string[]) {
- // eslint-disable-next-line no-console
- console.info(args);
- },
- warn(...args: string[]) {
- // eslint-disable-next-line no-console
- console.warn(args);
- },
- error(...args: string[]) {
- // eslint-disable-next-line no-console
- console.error(args);
- },
- },
- fileSystem: {
- resolve: path.resolve,
- exists: fs.existsSync,
- dirname: path.dirname,
- relative: path.relative,
- readFile: fs.readFileSync,
- // Node.JS types don't overlap the Compiler types.
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- } as any,
- });
-
- return linkerPlugin;
+ // Strip sourcemaps if they should not be used
+ return options.isAlreadyStripped ? code : removeSourceMappingURL(code);
}
diff --git a/packages/angular/build/src/tools/esbuild/javascript-transformer.ts b/packages/angular/build/src/tools/esbuild/javascript-transformer.ts
index b728a0f599e2..3ba0dfff45b1 100644
--- a/packages/angular/build/src/tools/esbuild/javascript-transformer.ts
+++ b/packages/angular/build/src/tools/esbuild/javascript-transformer.ts
@@ -6,12 +6,41 @@
* found in the LICENSE file at https://angular.dev/license
*/
-import { createHash } from 'node:crypto';
import { readFile } from 'node:fs/promises';
+import { createContentHash } from '../../utils/hash';
import { IMPORT_EXEC_ARGV } from '../../utils/server-rendering/esm-in-memory-loader/utils';
+import { removeSourceMappingURL } from '../../utils/source-map';
import { WorkerPool, WorkerPoolOptions } from '../../utils/worker-pool';
import { Cache } from './cache';
+const LINKER_DECLARATION_PREFIX = 'ɵɵngDeclare';
+const LINKER_DECLARATION_PREFIX_BYTES = Buffer.from(LINKER_DECLARATION_PREFIX, 'utf-8');
+
+/**
+ * Determines whether JavaScript code requires Angular linker processing.
+ *
+ * @param path The full path to the file.
+ * @param data The data (string or Buffer) of the file.
+ * @returns True if the code contains an Angular partial declaration; otherwise false.
+ */
+function requiresLinking(path: string, data: string | Uint8Array): boolean {
+ // @angular/core and @angular/compiler will cause false positives
+ // Also, TypeScript files do not require linking
+ if (/[\\/]@angular[\\/](?:compiler|core)[\\/]|\.[cm]?tsx?$/.test(path)) {
+ return false;
+ }
+
+ if (typeof data === 'string') {
+ return data.includes(LINKER_DECLARATION_PREFIX);
+ }
+
+ const dataBuffer = Buffer.isBuffer(data)
+ ? data
+ : Buffer.from(data.buffer, data.byteOffset, data.byteLength);
+
+ return dataBuffer.includes(LINKER_DECLARATION_PREFIX_BYTES);
+}
+
/**
* Transformation options that should apply to all transformed files and data.
*/
@@ -34,11 +63,22 @@ export class JavaScriptTransformer {
#commonOptions: Required;
#fileCacheKeyBase: Uint8Array;
+ /** Queue of pending transformation tasks waiting for an active concurrency slot. */
+ #pendingTasks: { resolve: () => void; reject: (reason: Error) => void }[] = [];
+
+ /** Current count of actively executing transformation tasks. */
+ #activeTasks = 0;
+
+ /** Maximum number of transformation tasks allowed to execute concurrently. */
+ #maxConcurrent: number;
+
constructor(
options: JavaScriptTransformerOptions,
readonly maxThreads: number,
private readonly cache?: Cache,
) {
+ // Maintain 2 active tasks per worker thread to keep transformation pipelines fully saturated
+ this.#maxConcurrent = Math.max(1, maxThreads * 2);
// Extract options to ensure only the named options are serialized and sent to the worker
const {
sourcemap,
@@ -53,6 +93,34 @@ export class JavaScriptTransformer {
jit,
};
this.#fileCacheKeyBase = Buffer.from(JSON.stringify(this.#commonOptions), 'utf-8');
+ this.#workerPool = this.#ensureWorkerPool();
+ }
+
+ /**
+ * Executes a transformation action using a semaphore-based backpressure throttle.
+ * Prevents libuv thread pool saturation and excessive V8 heap accumulation.
+ * @param action A callback that produces a promise for the transformation result.
+ * @returns A promise resolving to the transformation result.
+ */
+ async #runWithThrottle(action: () => Promise): Promise {
+ if (this.#activeTasks >= this.#maxConcurrent) {
+ await new Promise((resolve, reject) => {
+ this.#pendingTasks.push({ resolve, reject });
+ });
+ } else {
+ this.#activeTasks++;
+ }
+
+ try {
+ return await action();
+ } finally {
+ const next = this.#pendingTasks.shift();
+ if (next) {
+ next.resolve();
+ } else {
+ this.#activeTasks--;
+ }
+ }
}
#ensureWorkerPool(): WorkerPool {
@@ -63,6 +131,8 @@ export class JavaScriptTransformer {
const workerPoolOptions: WorkerPoolOptions = {
filename: require.resolve('./javascript-transformer-worker'),
maxThreads: this.maxThreads,
+ minThreads: this.maxThreads,
+ workerData: this.#commonOptions,
};
// Prevent passing SSR `--import` (loader-hooks) from parent to child worker.
@@ -90,46 +160,37 @@ export class JavaScriptTransformer {
sideEffects?: boolean,
instrumentForCoverage?: boolean,
): Promise {
- const data = await readFile(filename);
-
- let result;
- let cacheKey;
- if (this.cache) {
- // Create a cache key from the file data and options that effect the output.
- // NOTE: If additional options are added, this may need to be updated.
- // TODO: Consider xxhash or similar instead of SHA256
- const hash = createHash('sha256');
- hash.update(`${!!skipLinker}--${!!sideEffects}`);
- hash.update(data);
- hash.update(this.#fileCacheKeyBase);
- cacheKey = hash.digest('hex');
+ return this.#runWithThrottle(async () => {
+ const data = await readFile(filename);
- try {
- result = await this.cache?.get(cacheKey);
- } catch {
- // Failure to get the value should not fail the transform
+ let cacheKey: string | undefined;
+ if (this.cache) {
+ // Create a cache key from the file data and options that effect the output.
+ // NOTE: If additional options are added, this may need to be updated.
+ const hasher = createContentHash();
+ hasher.update(`${!!skipLinker}--${!!sideEffects}`);
+ hasher.update(data);
+ hasher.update(this.#fileCacheKeyBase);
+ cacheKey = hasher.digest();
+
+ try {
+ const cached = await this.cache.get(cacheKey);
+ if (cached !== undefined) {
+ return cached;
+ }
+ } catch {
+ // Failure to get the value should not fail the transform
+ }
}
- }
- if (result === undefined) {
- // If there is no cache or no cached entry, process the file
- result = (await this.#ensureWorkerPool().run(
- {
- filename,
- data,
- skipLinker,
- sideEffects,
- instrumentForCoverage,
- ...this.#commonOptions,
- },
- {
- // The below is disable as with Yarn PNP this causes build failures with the below message
- // `Unable to deserialize cloned data`.
- transferList: process.versions.pnp ? undefined : [data.buffer],
- },
- )) as Uint8Array;
-
- // If there is a cache then store the result
+ const result = await this.transformData(
+ filename,
+ data,
+ !!skipLinker,
+ sideEffects,
+ instrumentForCoverage,
+ );
+
if (this.cache && cacheKey) {
try {
await this.cache.put(cacheKey, result);
@@ -137,9 +198,9 @@ export class JavaScriptTransformer {
// Failure to store the value in the cache should not fail the transform
}
}
- }
- return result;
+ return result;
+ });
}
/**
@@ -153,32 +214,50 @@ export class JavaScriptTransformer {
*/
async transformData(
filename: string,
- data: string,
+ data: string | Uint8Array,
skipLinker: boolean,
sideEffects?: boolean,
instrumentForCoverage?: boolean,
): Promise {
+ const shouldLink = !skipLinker && requiresLinking(filename, data);
+
// Perform a quick test to determine if the data needs any transformations.
// This allows directly returning the data without the worker communication overhead.
- if (skipLinker && !this.#commonOptions.advancedOptimizations && !instrumentForCoverage) {
+ if (!shouldLink && !this.#commonOptions.advancedOptimizations && !instrumentForCoverage) {
const keepSourcemap =
this.#commonOptions.sourcemap &&
(!!this.#commonOptions.thirdPartySourcemaps || !/[\\/]node_modules[\\/]/.test(filename));
- return Buffer.from(
- keepSourcemap ? data : data.replace(/^\/\/# sourceMappingURL=[^\r\n]*/gm, ''),
- 'utf-8',
- );
+ if (typeof data === 'string') {
+ return Buffer.from(keepSourcemap ? data : removeSourceMappingURL(data), 'utf-8');
+ }
+
+ return keepSourcemap ? data : removeSourceMappingURL(data);
}
- return this.#ensureWorkerPool().run({
- filename,
- data,
- skipLinker,
- sideEffects,
- instrumentForCoverage,
- ...this.#commonOptions,
- });
+ // Only standalone (non-pooled) ArrayBuffers can be transferred across worker threads.
+ // Node.js shares an internal 8KB ArrayBuffer pool for small buffers, and transferring
+ // a pooled buffer will throw a DataCloneError because detaching it invalidates other slices.
+ // In addition, SharedArrayBuffers cannot be transferred, and Yarn PnP has deserialization issues.
+ const isTransferable =
+ typeof data !== 'string' &&
+ data.buffer instanceof ArrayBuffer &&
+ data.byteOffset === 0 &&
+ data.byteLength === data.buffer.byteLength &&
+ !process.versions.pnp;
+
+ return this.#ensureWorkerPool().run(
+ {
+ filename,
+ data,
+ skipLinker: !shouldLink,
+ sideEffects,
+ instrumentForCoverage,
+ },
+ {
+ transferList: isTransferable ? [data.buffer] : undefined,
+ },
+ );
}
/**
@@ -186,6 +265,12 @@ export class JavaScriptTransformer {
* @returns A void promise that resolves when closing is complete.
*/
async close(): Promise {
+ const pending = this.#pendingTasks;
+ this.#pendingTasks = [];
+ for (const task of pending) {
+ task.reject(new Error('JavaScriptTransformer closed.'));
+ }
+
if (this.#workerPool) {
try {
await this.#workerPool.destroy();
diff --git a/packages/angular/build/src/tools/esbuild/javascript-transformer_spec.ts b/packages/angular/build/src/tools/esbuild/javascript-transformer_spec.ts
new file mode 100644
index 000000000000..b1cdeec07b44
--- /dev/null
+++ b/packages/angular/build/src/tools/esbuild/javascript-transformer_spec.ts
@@ -0,0 +1,377 @@
+/**
+ * @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 { JavaScriptTransformer } from './javascript-transformer';
+
+describe('JavaScriptTransformer sourcemaps', () => {
+ let transformer: JavaScriptTransformer;
+
+ afterEach(async () => {
+ await transformer?.close();
+ });
+
+ function extractSourcemap(code: string): Record | null {
+ const match = code.match(
+ /\/\/# sourceMappingURL=data:application\/json;charset=utf-8;base64,(.+)/,
+ );
+ if (!match) {
+ return null;
+ }
+
+ return JSON.parse(Buffer.from(match[1], 'base64').toString('utf-8'));
+ }
+
+ it('should remap correctly when only advanced optimizations are applied', async () => {
+ transformer = new JavaScriptTransformer(
+ {
+ sourcemap: true,
+ advancedOptimizations: true,
+ },
+ 1,
+ );
+
+ const inputMap = {
+ version: 3,
+ sources: ['src/app.ts'],
+ sourcesContent: ['const x = new SomeClass();'],
+ mappings: 'AAAA',
+ names: [],
+ };
+ const base64Map = Buffer.from(JSON.stringify(inputMap)).toString('base64');
+ const input = `var x = new SomeClass();\n//# sourceMappingURL=data:application/json;base64,${base64Map}`;
+
+ const result = await transformer.transformData('src/app.js', input, true);
+ const text = Buffer.from(result).toString('utf-8');
+ const map = extractSourcemap(text);
+
+ expect(map).toBeDefined();
+ expect(map?.['version']).toBe(3);
+ expect(map?.['sources']).toContain('src/app.ts');
+ expect(typeof map?.['mappings']).toBe('string');
+ expect((map?.['mappings'] as string).length).toBeGreaterThan(0);
+ });
+
+ it('should remap correctly when only linking is applied', async () => {
+ transformer = new JavaScriptTransformer(
+ {
+ sourcemap: true,
+ thirdPartySourcemaps: true,
+ },
+ 1,
+ );
+
+ const inputMap = {
+ version: 3,
+ sources: ['node_modules/my-lib/directive.ts'],
+ sourcesContent: ['export class MyDirective {}'],
+ mappings: 'AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA',
+ names: [],
+ };
+ const base64Map = Buffer.from(JSON.stringify(inputMap)).toString('base64');
+ const input = `
+ import * as i0 from "@angular/core";
+ export class MyDirective {}
+ MyDirective.ɵdir = i0.ɵɵngDeclareDirective({
+ minVersion: "12.0.0",
+ version: "14.0.0",
+ ngImport: i0,
+ type: MyDirective,
+ selector: "[my-dir]"
+ });
+ //# sourceMappingURL=data:application/json;base64,${base64Map}
+ `;
+
+ const result = await transformer.transformData(
+ 'node_modules/my-lib/directive.js',
+ input,
+ false,
+ );
+ const text = Buffer.from(result).toString('utf-8');
+ const map = extractSourcemap(text);
+
+ expect(map).toBeDefined();
+ expect(map?.['version']).toBe(3);
+ expect(map?.['sources']).toContain('node_modules/my-lib/directive.ts');
+ expect(typeof map?.['mappings']).toBe('string');
+ expect((map?.['mappings'] as string).length).toBeGreaterThan(0);
+ });
+
+ it('should defer and chain remapping when both linking and advanced optimizations are applied', async () => {
+ transformer = new JavaScriptTransformer(
+ {
+ sourcemap: true,
+ thirdPartySourcemaps: true,
+ advancedOptimizations: true,
+ },
+ 1,
+ );
+
+ const inputMap = {
+ version: 3,
+ sources: ['node_modules/my-lib/component.ts'],
+ sourcesContent: ['export class MyComponent {}'],
+ mappings: 'AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA',
+ names: [],
+ };
+ const base64Map = Buffer.from(JSON.stringify(inputMap)).toString('base64');
+ const input = `
+ import * as i0 from "@angular/core";
+ export class MyComponent {}
+ MyComponent.ɵcmp = i0.ɵɵngDeclareComponent({
+ minVersion: "12.0.0",
+ version: "14.0.0",
+ ngImport: i0,
+ type: MyComponent,
+ selector: "my-cmp",
+ template: ""
+ });
+ //# sourceMappingURL=data:application/json;base64,${base64Map}
+ `;
+
+ const result = await transformer.transformData(
+ 'node_modules/my-lib/component.js',
+ input,
+ false,
+ );
+ const text = Buffer.from(result).toString('utf-8');
+ const map = extractSourcemap(text);
+
+ expect(map).toBeDefined();
+ expect(map?.['version']).toBe(3);
+ expect(map?.['sources']).toContain('node_modules/my-lib/component.ts');
+ expect(typeof map?.['mappings']).toBe('string');
+ expect((map?.['mappings'] as string).length).toBeGreaterThan(0);
+ });
+
+ it('should produce a valid sourcemap when no input sourcemap is present', async () => {
+ transformer = new JavaScriptTransformer(
+ {
+ sourcemap: true,
+ advancedOptimizations: true,
+ },
+ 1,
+ );
+
+ const input = 'var x = new SomeClass();';
+ const result = await transformer.transformData('src/app.js', input, true);
+ const text = Buffer.from(result).toString('utf-8');
+ const map = extractSourcemap(text);
+
+ expect(map).toBeDefined();
+ expect(map?.['version']).toBe(3);
+ expect(map?.['sources']).toContain('src/app.js');
+ expect(typeof map?.['mappings']).toBe('string');
+ expect((map?.['mappings'] as string).length).toBeGreaterThan(0);
+ });
+
+ it('should remap correctly when coverage instrumentation is applied with an input sourcemap', async () => {
+ transformer = new JavaScriptTransformer(
+ {
+ sourcemap: true,
+ },
+ 1,
+ );
+
+ const inputMap = {
+ version: 3,
+ sources: ['src/counter.ts'],
+ sourcesContent: ['export function add(a: number, b: number) { return a + b; }'],
+ mappings: 'AAAA',
+ names: [],
+ };
+ const base64Map = Buffer.from(JSON.stringify(inputMap)).toString('base64');
+ const input = `export function add(a, b) { return a + b; }\n//# sourceMappingURL=data:application/json;base64,${base64Map}`;
+
+ const result = await transformer.transformData(
+ 'src/counter.js',
+ input,
+ true,
+ undefined,
+ true /* instrumentForCoverage */,
+ );
+ const text = Buffer.from(result).toString('utf-8');
+ const map = extractSourcemap(text);
+
+ expect(map).toBeDefined();
+ expect(map?.['version']).toBe(3);
+ expect(map?.['sources']).toContain('src/counter.ts');
+ expect(typeof map?.['mappings']).toBe('string');
+ expect((map?.['mappings'] as string).length).toBeGreaterThan(0);
+ });
+
+ it('should defer and chain remapping when coverage instrumentation and advanced optimizations are applied', async () => {
+ transformer = new JavaScriptTransformer(
+ {
+ sourcemap: true,
+ advancedOptimizations: true,
+ },
+ 1,
+ );
+
+ const inputMap = {
+ version: 3,
+ sources: ['src/app.ts'],
+ sourcesContent: ['const x = new SomeClass();'],
+ mappings: 'AAAA',
+ names: [],
+ };
+ const base64Map = Buffer.from(JSON.stringify(inputMap)).toString('base64');
+ const input = `var x = new SomeClass();\n//# sourceMappingURL=data:application/json;base64,${base64Map}`;
+
+ const result = await transformer.transformData(
+ 'src/app.js',
+ input,
+ true,
+ undefined,
+ true /* instrumentForCoverage */,
+ );
+ const text = Buffer.from(result).toString('utf-8');
+ const map = extractSourcemap(text);
+
+ expect(map).toBeDefined();
+ expect(map?.['version']).toBe(3);
+ expect(map?.['sources']).toContain('src/app.ts');
+ expect(typeof map?.['mappings']).toBe('string');
+ expect((map?.['mappings'] as string).length).toBeGreaterThan(0);
+ });
+
+ it('should accept a Uint8Array input in transformData', async () => {
+ transformer = new JavaScriptTransformer(
+ {
+ sourcemap: true,
+ advancedOptimizations: true,
+ },
+ 1,
+ );
+
+ const inputBuffer = Buffer.from('var x = new SomeClass();', 'utf-8');
+ const result = await transformer.transformData('src/app.js', inputBuffer, true);
+ const text = Buffer.from(result).toString('utf-8');
+ const map = extractSourcemap(text);
+
+ expect(map).toBeDefined();
+ expect(map?.['version']).toBe(3);
+ expect(map?.['sources']).toContain('src/app.js');
+ expect(typeof map?.['mappings']).toBe('string');
+ });
+
+ it('should strip trailing sourcemap comments from Uint8Array input on fast-path', async () => {
+ transformer = new JavaScriptTransformer(
+ {
+ sourcemap: false,
+ },
+ 1,
+ );
+
+ const inputBuffer = Buffer.from(
+ 'console.log("hello");\n//# sourceMappingURL=app.js.map',
+ 'utf-8',
+ );
+ const result = await transformer.transformData('node_modules/my-lib/lib.js', inputBuffer, true);
+ const text = Buffer.from(result).toString('utf-8');
+
+ expect(text).toBe('console.log("hello");\n');
+ });
+
+ it('should return Uint8Array input untouched on fast-path when no sourcemap comment is present', async () => {
+ transformer = new JavaScriptTransformer(
+ {
+ sourcemap: false,
+ },
+ 1,
+ );
+
+ const inputBuffer = Buffer.from('console.log("hello");\nconst x = 1;', 'utf-8');
+ const result = await transformer.transformData('node_modules/my-lib/lib.js', inputBuffer, true);
+
+ expect(result).toBe(inputBuffer);
+ });
+
+ it('should return Uint8Array untouched when skipLinker is false but file contains no linker declarations', async () => {
+ transformer = new JavaScriptTransformer(
+ {
+ sourcemap: false,
+ },
+ 1,
+ );
+
+ const inputBuffer = Buffer.from('console.log("no linking required");\nconst x = 1;', 'utf-8');
+ const result = await transformer.transformData(
+ 'node_modules/my-lib/lib.js',
+ inputBuffer,
+ false, // skipLinker: false
+ );
+
+ expect(result).toBe(inputBuffer);
+ });
+
+ it('should bypass worker and skip linking for @angular/core and @angular/compiler paths', async () => {
+ transformer = new JavaScriptTransformer(
+ {
+ sourcemap: false,
+ },
+ 1,
+ );
+
+ const inputBuffer = Buffer.from('export const ɵɵngDeclareDirective = () => {};', 'utf-8');
+ const result = await transformer.transformData(
+ 'node_modules/@angular/core/fesm2022/core.mjs',
+ inputBuffer,
+ false,
+ );
+
+ expect(result).toBe(inputBuffer);
+ });
+
+ it('should bypass worker and skip linking for TypeScript file extensions (.ts, .tsx, .mts, .cts)', async () => {
+ transformer = new JavaScriptTransformer(
+ {
+ sourcemap: false,
+ },
+ 1,
+ );
+
+ const inputBuffer = Buffer.from('export const ɵɵngDeclareDirective = () => {};', 'utf-8');
+
+ for (const ext of ['.ts', '.tsx', '.mts', '.cts']) {
+ const result = await transformer.transformData(`src/app/directive${ext}`, inputBuffer, false);
+
+ expect(result).toBe(inputBuffer);
+ }
+ });
+
+ it('should not exclude packages with similar prefixes such as @angular/compiler-cli', async () => {
+ transformer = new JavaScriptTransformer(
+ {
+ sourcemap: false,
+ },
+ 1,
+ );
+
+ const input = `
+ import * as i0 from "@angular/core";
+ export class MyDirective {}
+ MyDirective.ɵdir = i0.ɵɵngDeclareDirective({
+ minVersion: "12.0.0",
+ version: "14.0.0",
+ ngImport: i0,
+ type: MyDirective,
+ selector: "[my-dir]"
+ });
+ `;
+
+ const result = await transformer.transformData(
+ 'node_modules/@angular/compiler-cli/test.js',
+ input,
+ false,
+ );
+ const text = Buffer.from(result).toString('utf-8');
+
+ expect(text).not.toContain('i0.ɵɵngDeclareDirective');
+ });
+});
diff --git a/packages/angular/build/src/tools/esbuild/license-extractor.ts b/packages/angular/build/src/tools/esbuild/license-extractor.ts
index 6629bbc387bf..4c73012033bb 100644
--- a/packages/angular/build/src/tools/esbuild/license-extractor.ts
+++ b/packages/angular/build/src/tools/esbuild/license-extractor.ts
@@ -7,7 +7,7 @@
*/
import type { Metafile } from 'esbuild';
-import { readFile } from 'node:fs/promises';
+import { readFile, readdir } from 'node:fs/promises';
import path from 'node:path';
/**
@@ -30,9 +30,9 @@ const NODE_MODULE_SEGMENT = 'node_modules';
const CUSTOM_LICENSE_TEXT = 'SEE LICENSE IN ';
/**
- * A list of commonly named license files found within packages.
+ * A regular expression for commonly named license files found within packages.
*/
-const LICENSE_FILES = ['LICENSE', 'LICENSE.txt', 'LICENSE.md'];
+const LICENSE_FILE_REGEXP = /^(?:mit-)?licen[cs]e(?:$|[-._])/i;
/**
* Header text that will be added to the top of the output license extraction file.
@@ -60,115 +60,132 @@ const EXTRACTION_FILE_SEPARATOR = '-'.repeat(80) + '\n';
* @param rootDirectory The root directory of the workspace.
* @returns A string containing the content of the output licenses file.
*/
-export async function extractLicenses(metafile: Metafile, rootDirectory: string) {
+export async function extractLicenses(
+ metafiles: Metafile[],
+ rootDirectory: string,
+): Promise {
let extractedLicenseContent = `${EXTRACTION_FILE_HEADER}\n${EXTRACTION_FILE_SEPARATOR}`;
const seenPaths = new Set();
+ const seenPackageDirectories = new Set();
const seenPackages = new Set();
- for (const entry of Object.values(metafile.outputs)) {
- for (const [inputPath, { bytesInOutput }] of Object.entries(entry.inputs)) {
- // Skip if not included in output
- if (bytesInOutput <= 0) {
- continue;
- }
+ for (const metafile of metafiles) {
+ for (const entry of Object.values(metafile.outputs)) {
+ for (const [inputPath, { bytesInOutput }] of Object.entries(entry.inputs)) {
+ // Skip if not included in output
+ if (bytesInOutput <= 0) {
+ continue;
+ }
- // Skip already processed paths
- if (seenPaths.has(inputPath)) {
- continue;
- }
- seenPaths.add(inputPath);
+ // Skip already processed paths
+ if (seenPaths.has(inputPath)) {
+ continue;
+ }
+ seenPaths.add(inputPath);
- // Skip non-package paths
- if (!inputPath.includes(NODE_MODULE_SEGMENT)) {
- continue;
- }
+ // Skip non-package paths
+ if (!inputPath.includes(NODE_MODULE_SEGMENT)) {
+ continue;
+ }
- // Extract the package name from the path
- let baseDirectory = path.join(rootDirectory, inputPath);
- let nameOrScope, nameOrFile;
- let found = false;
- while (baseDirectory !== path.dirname(baseDirectory)) {
- const segment = path.basename(baseDirectory);
- if (segment === NODE_MODULE_SEGMENT) {
- found = true;
- break;
+ // Extract the package name from the path
+ let baseDirectory = path.join(rootDirectory, inputPath);
+ let nameOrScope, nameOrFile;
+ let found = false;
+ while (baseDirectory !== path.dirname(baseDirectory)) {
+ const segment = path.basename(baseDirectory);
+ if (segment === NODE_MODULE_SEGMENT) {
+ found = true;
+ break;
+ }
+
+ nameOrFile = nameOrScope;
+ nameOrScope = segment;
+ baseDirectory = path.dirname(baseDirectory);
}
- nameOrFile = nameOrScope;
- nameOrScope = segment;
- baseDirectory = path.dirname(baseDirectory);
- }
+ // Skip non-package path edge cases that are not caught in the includes check above
+ if (!found || !nameOrScope) {
+ continue;
+ }
- // Skip non-package path edge cases that are not caught in the includes check above
- if (!found || !nameOrScope) {
- continue;
- }
+ const packageName = nameOrScope.startsWith('@')
+ ? `${nameOrScope}/${nameOrFile}`
+ : nameOrScope;
+ const packageDirectory = path.join(baseDirectory, packageName);
- const packageName = nameOrScope.startsWith('@')
- ? `${nameOrScope}/${nameOrFile}`
- : nameOrScope;
- const packageDirectory = path.join(baseDirectory, packageName);
-
- // Load the package's metadata to find the package's name, version, and license type
- const packageJsonPath = path.join(packageDirectory, 'package.json');
- let packageJson;
- try {
- packageJson = JSON.parse(await readFile(packageJsonPath, 'utf-8')) as {
- name: string;
- version: string;
- // The object form is deprecated and should only be present in old packages
- license?: string | { type: string };
- };
- } catch {
- // Invalid package
- continue;
- }
+ if (seenPackageDirectories.has(packageDirectory)) {
+ continue;
+ }
+ seenPackageDirectories.add(packageDirectory);
+
+ // Load the package's metadata to find the package's name, version, and license type
+ const packageJsonPath = path.join(packageDirectory, 'package.json');
+ let packageJson;
+ try {
+ packageJson = JSON.parse(await readFile(packageJsonPath, 'utf-8')) as {
+ name: string;
+ version: string;
+ // The object form is deprecated and should only be present in old packages
+ license?: string | { type: string };
+ };
+ } catch {
+ // Invalid package
+ continue;
+ }
- // Skip already processed packages
- const packageId = `${packageName}@${packageJson.version}`;
- if (seenPackages.has(packageId)) {
- continue;
- }
- seenPackages.add(packageId);
-
- // Attempt to find license text inside package
- let licenseText = '';
- if (
- typeof packageJson.license === 'string' &&
- packageJson.license.toLowerCase().startsWith(CUSTOM_LICENSE_TEXT)
- ) {
- // Attempt to load the package's custom license
- let customLicensePath;
- const customLicenseFile = path.normalize(
- packageJson.license.slice(CUSTOM_LICENSE_TEXT.length + 1).trim(),
- );
- if (customLicenseFile.startsWith('..') || path.isAbsolute(customLicenseFile)) {
- // Path is attempting to access files outside of the package
- // TODO: Issue warning?
- } else {
- customLicensePath = path.join(packageDirectory, customLicenseFile);
- try {
- licenseText = await readFile(customLicensePath, 'utf-8');
- break;
- } catch {}
+ // Skip already processed packages
+ const packageId = `${packageName}@${packageJson.version}`;
+ if (seenPackages.has(packageId)) {
+ continue;
}
- } else {
- // Search for a license file within the root of the package
- for (const potentialLicense of LICENSE_FILES) {
- const packageLicensePath = path.join(packageDirectory, potentialLicense);
- try {
- licenseText = await readFile(packageLicensePath, 'utf-8');
- break;
- } catch {}
+ seenPackages.add(packageId);
+
+ // Attempt to find license text inside package
+ let licenseText = '';
+ if (
+ typeof packageJson.license === 'string' &&
+ packageJson.license.toUpperCase().startsWith(CUSTOM_LICENSE_TEXT)
+ ) {
+ // Attempt to load the package's custom license
+ let customLicensePath;
+ const customLicenseFile = path.normalize(
+ packageJson.license.slice(CUSTOM_LICENSE_TEXT.length).trim(),
+ );
+ if (customLicenseFile.startsWith('..') || path.isAbsolute(customLicenseFile)) {
+ // Path is attempting to access files outside of the package
+ // TODO: Issue warning?
+ } else {
+ customLicensePath = path.join(packageDirectory, customLicenseFile);
+ try {
+ licenseText = await readFile(customLicensePath, 'utf-8');
+ } catch {}
+ }
+ } else {
+ // Search for a license file within the root of the package
+ const entries = await readdir(packageDirectory, { withFileTypes: true }).catch(() => []);
+
+ for (const entry of entries) {
+ if (
+ (entry.isFile() || entry.isSymbolicLink()) &&
+ LICENSE_FILE_REGEXP.test(entry.name)
+ ) {
+ const packageLicensePath = path.join(packageDirectory, entry.name);
+ try {
+ licenseText = await readFile(packageLicensePath, 'utf-8');
+ break;
+ } catch {}
+ }
+ }
}
- }
- // Generate the package's license entry in the output content
- extractedLicenseContent += `Package: ${packageJson.name}\n`;
- extractedLicenseContent += `License: ${JSON.stringify(packageJson.license, null, 2)}\n`;
- extractedLicenseContent += `\n${licenseText}\n`;
- extractedLicenseContent += EXTRACTION_FILE_SEPARATOR;
+ // Generate the package's license entry in the output content
+ extractedLicenseContent += `Package: ${packageJson.name}\n`;
+ extractedLicenseContent += `License: ${JSON.stringify(packageJson.license, null, 2)}\n`;
+ extractedLicenseContent += `\n${licenseText}\n`;
+ extractedLicenseContent += EXTRACTION_FILE_SEPARATOR;
+ }
}
}
diff --git a/packages/angular/build/src/tools/esbuild/lmdb-cache-store.ts b/packages/angular/build/src/tools/esbuild/lmdb-cache-store.ts
index dba108285342..d8f95b8de9da 100644
--- a/packages/angular/build/src/tools/esbuild/lmdb-cache-store.ts
+++ b/packages/angular/build/src/tools/esbuild/lmdb-cache-store.ts
@@ -7,9 +7,9 @@
*/
import { RootDatabase, open } from 'lmdb';
-import { Cache, CacheStore } from './cache';
+import { Cache, PersistentCacheStore } from './cache';
-export class LmdbCacheStore implements CacheStore {
+export class LmdbCacheStore implements PersistentCacheStore {
readonly #cacheFileUrl;
#db: RootDatabase | undefined;
diff --git a/packages/angular/build/src/tools/esbuild/load-result-cache.ts b/packages/angular/build/src/tools/esbuild/load-result-cache.ts
index 30067486a384..00f803f2fdc7 100644
--- a/packages/angular/build/src/tools/esbuild/load-result-cache.ts
+++ b/packages/angular/build/src/tools/esbuild/load-result-cache.ts
@@ -10,7 +10,7 @@ import type { OnLoadResult, PluginBuild } from 'esbuild';
import { normalize } from 'node:path';
export interface LoadResultCache {
- get(path: string): OnLoadResult | undefined;
+ get(path: string): OnLoadResult | Promise | undefined;
put(path: string, result: OnLoadResult): Promise;
readonly watchFiles: ReadonlyArray;
}
@@ -25,7 +25,7 @@ export function createCachedLoad(
return async (args) => {
const loadCacheKey = `${args.namespace}:${args.path}`;
- let result: OnLoadResult | null | undefined = cache.get(loadCacheKey);
+ let result: OnLoadResult | null | undefined = await cache.get(loadCacheKey);
if (result === undefined) {
result = await callback(args);
@@ -35,7 +35,9 @@ export function createCachedLoad(
// Ensure requested path is included if it was a resolved file
if (args.namespace === 'file') {
result.watchFiles ??= [];
- result.watchFiles.push(args.path);
+ if (!result.watchFiles.includes(args.path)) {
+ result.watchFiles.push(args.path);
+ }
}
await cache.put(loadCacheKey, result);
}
@@ -48,12 +50,26 @@ export function createCachedLoad(
export class MemoryLoadResultCache implements LoadResultCache {
#loadResults = new Map();
#fileDependencies = new Map>();
+ #watchFilesPerKey = new Map>();
get(path: string): OnLoadResult | undefined {
return this.#loadResults.get(path);
}
async put(path: string, result: OnLoadResult): Promise {
+ if (result.errors && result.errors.length > 0) {
+ const previousWatchFiles = this.#watchFilesPerKey.get(path);
+ if (previousWatchFiles) {
+ result.watchFiles = Array.from(
+ new Set([...(result.watchFiles ?? []), ...previousWatchFiles]),
+ );
+ }
+ } else if (result.watchFiles && result.watchFiles.length > 0) {
+ this.#watchFilesPerKey.set(path, [...result.watchFiles]);
+ } else {
+ this.#watchFilesPerKey.delete(path);
+ }
+
this.#loadResults.set(path, result);
if (result.watchFiles) {
for (const watchFile of result.watchFiles) {
@@ -90,4 +106,10 @@ export class MemoryLoadResultCache implements LoadResultCache {
// are namespaced request paths and not disk-based file paths.
return [...this.#fileDependencies.keys()];
}
+
+ clear(): void {
+ this.#loadResults.clear();
+ this.#fileDependencies.clear();
+ this.#watchFilesPerKey.clear();
+ }
}
diff --git a/packages/angular/build/src/tools/esbuild/load-result-cache_spec.ts b/packages/angular/build/src/tools/esbuild/load-result-cache_spec.ts
new file mode 100644
index 000000000000..fb0396aea829
--- /dev/null
+++ b/packages/angular/build/src/tools/esbuild/load-result-cache_spec.ts
@@ -0,0 +1,85 @@
+/**
+ * @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 { MemoryLoadResultCache } from './load-result-cache';
+
+describe('MemoryLoadResultCache', () => {
+ let cache: MemoryLoadResultCache;
+
+ beforeEach(() => {
+ cache = new MemoryLoadResultCache();
+ });
+
+ it('should store and retrieve results', async () => {
+ const result = {
+ contents: 'body { color: red; }',
+ loader: 'css' as const,
+ };
+
+ await cache.put('file:/test/styles.css', result);
+ const cached = cache.get('file:/test/styles.css');
+
+ expect(cached).toBe(result);
+ });
+
+ it('should track watch files in fileDependencies', async () => {
+ const result = {
+ contents: 'body { color: red; }',
+ loader: 'css' as const,
+ watchFiles: ['/test/styles.css', '/test/theme.json'],
+ };
+
+ await cache.put('file:/test/styles.css', result);
+
+ expect(cache.watchFiles).toContain('/test/styles.css');
+ expect(cache.watchFiles).toContain('/test/theme.json');
+ });
+
+ it('should invalidate cached results when a dependency changes', async () => {
+ const result = {
+ contents: 'body { color: red; }',
+ loader: 'css' as const,
+ watchFiles: ['/test/styles.css', '/test/theme.json'],
+ };
+
+ await cache.put('file:/test/styles.css', result);
+ expect(cache.get('file:/test/styles.css')).toBe(result);
+
+ const invalidated = cache.invalidate('/test/theme.json');
+ expect(invalidated).toBeTrue();
+ expect(cache.get('file:/test/styles.css')).toBeUndefined();
+ });
+
+ it('should preserve previous watch files when caching an error result', async () => {
+ const successResult = {
+ contents: 'body { color: red; }',
+ loader: 'css' as const,
+ watchFiles: ['/test/styles.css', '/test/theme.json'],
+ };
+
+ await cache.put('file:/test/styles.css', successResult);
+ cache.invalidate('/test/theme.json');
+
+ // Simulate an incremental rebuild error result that only has the entry file in watchFiles
+ const errorResult = {
+ errors: [{ text: 'Syntax error in theme.json' }],
+ watchFiles: ['/test/styles.css'],
+ };
+
+ await cache.put('file:/test/styles.css', errorResult);
+
+ // Both the entry file and the previous dependency should be tracked
+ expect(cache.watchFiles).toContain('/test/styles.css');
+ expect(cache.watchFiles).toContain('/test/theme.json');
+
+ // Invalidating the dependency should clear the cached error result
+ const invalidated = cache.invalidate('/test/theme.json');
+ expect(invalidated).toBeTrue();
+ expect(cache.get('file:/test/styles.css')).toBeUndefined();
+ });
+});
diff --git a/packages/angular/build/src/tools/esbuild/persistent-load-result-cache.ts b/packages/angular/build/src/tools/esbuild/persistent-load-result-cache.ts
new file mode 100644
index 000000000000..9d1e3ff5cfba
--- /dev/null
+++ b/packages/angular/build/src/tools/esbuild/persistent-load-result-cache.ts
@@ -0,0 +1,381 @@
+/**
+ * @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
+ */
+
+/**
+ * @fileoverview
+ * Implements a generic, two-tier (L1 memory + L2 persistent disk store) caching system for
+ * esbuild plugin `OnLoadResult` outcomes across the `@angular/build` compiler pipeline.
+ *
+ * Supported Module Types:
+ * 1. **Disk File Modules** (`file:` namespace): Standard disk-based source files (TS, JS, CSS, Sass, Less).
+ * Cache keys are computed using the root `globalConfigHash`, file path, and file content. Validity is
+ * verified via fast-path metadata (`mtimeMs` + `size`) with fallback to content hashing (`sha256`).
+ * 2. **Custom Plugin Namespace Modules** (e.g. `angular:script/global`, `sass:`): Modules loaded through
+ * custom esbuild namespaces that resolve to disk source files.
+ * 3. **Virtual Modules & Remote Resources** (e.g. `angular:styles/component`, `css-inline-fonts`): Synthetic
+ * in-memory modules or remote asset declarations whose compiled outcomes depend on parent source file
+ * dependencies (`watchFiles`) or global configuration options (`globalConfigHash`).
+ *
+ * Key Exported Types:
+ * - {@link PersistentLoadResultCache}: Primary two-tier cache manager implementing `LoadResultCache`.
+ * - {@link CachedLoadResultEntry}: Serialized structure persisted to disk for cached esbuild `OnLoadResult` items.
+ * - {@link CachedDependencyMetadata}: Per-dependency file metadata (`hash`, `mtimeMs`, `size`) used for cache validation and healing.
+ */
+
+import type { Loader, OnLoadResult, PartialMessage } from 'esbuild';
+import { readFile, stat } from 'node:fs/promises';
+import { isAbsolute } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { calculateHash, createContentHash } from '../../utils/hash';
+import type { Cache as PersistentCacheStore } from './cache';
+import { LoadResultCache, MemoryLoadResultCache } from './load-result-cache';
+
+/**
+ * Metadata for a single watch file dependency.
+ */
+export interface CachedDependencyMetadata {
+ hash: string;
+ mtimeMs: number;
+ size: number;
+}
+
+/**
+ * Serialized representation of any esbuild load result stored in persistent cache.
+ */
+export interface CachedLoadResultEntry {
+ /** Compiled output string or binary data */
+ contents: string | Uint8Array;
+
+ /** esbuild loader type */
+ loader?: Loader;
+
+ /** Absolute paths of all imported/watched dependency files */
+ watchFiles: string[];
+
+ /** Absolute paths of all watched directories */
+ watchDirs?: string[];
+
+ /** Map of watchFile absolute paths to dependency metadata */
+ watchFilesMetadata: Record;
+
+ /** Warnings emitted during load processing */
+ warnings?: PartialMessage[];
+
+ /** Errors emitted during load processing */
+ errors?: PartialMessage[];
+}
+
+/**
+ * Calculates a unique cache key by updating the hash incrementally.
+ * This prevents implicit string coercion of large binary content buffers.
+ */
+function calculateCacheKey(
+ globalConfigHash: string,
+ path: string,
+ content: string | Uint8Array,
+): string {
+ const hasher = createContentHash();
+ hasher.update(globalConfigHash);
+ hasher.update('\0');
+ hasher.update(path);
+ hasher.update('\0');
+ hasher.update(content);
+
+ return hasher.digest();
+}
+
+/**
+ * Normalizes a namespaced cache key into a valid disk file path if one exists.
+ * Handles 'file:' URIs, OS platform differences, and custom plugin namespaces.
+ */
+export function extractDiskFilePath(path: string): string | undefined {
+ if (path.startsWith('file:')) {
+ const urlStr = path.startsWith('file://') ? path : 'file://' + path.slice(5);
+ try {
+ return fileURLToPath(urlStr);
+ } catch {
+ const candidate = path.slice(5);
+
+ return isAbsolute(candidate) ? candidate : undefined;
+ }
+ }
+
+ // Handle custom namespace prefix (e.g. "sass:/path/to/file")
+ // Ensure colonIndex > 1 to avoid treating Windows drive letters (e.g. "C:/") as namespace prefixes.
+ const colonIndex = path.indexOf(':');
+ if (colonIndex > 1) {
+ const candidatePath = path.slice(colonIndex + 1);
+ if (isAbsolute(candidatePath)) {
+ return candidatePath;
+ }
+ }
+
+ return isAbsolute(path) ? path : undefined;
+}
+
+/** Maximum number of concurrent file system read/stat operations to prevent OS file descriptor exhaustion. */
+const MAX_CONCURRENT_READS = 16;
+
+/**
+ * Maps an array asynchronously with a sliding worker pool to maintain full concurrency saturation.
+ */
+async function mapConcurrent(
+ items: T[],
+ limit: number,
+ fn: (item: T) => Promise,
+): Promise {
+ const results: R[] = new Array(items.length);
+ let index = 0;
+
+ const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
+ while (index < items.length) {
+ const i = index++;
+ results[i] = await fn(items[i]);
+ }
+ });
+
+ await Promise.all(workers);
+
+ return results;
+}
+
+/**
+ * Validates that all imported watch files exist on disk and their contents match.
+ * Performs a fast-path metadata check (mtime + size) first, falling back to content hashing.
+ * Heals/updates the cached metadata on disk if the content hash was valid but the metadata changed.
+ */
+async function validateAndHealCacheEntry(
+ watchFilesMetadata: Record | undefined,
+ store: PersistentCacheStore,
+ cacheKey: string,
+ cached: CachedLoadResultEntry,
+ targetFilePath?: string,
+): Promise {
+ if (!watchFilesMetadata) {
+ return false;
+ }
+
+ const watchFiles = Object.keys(watchFilesMetadata);
+ let healed = false;
+
+ const isValidResults = await mapConcurrent(watchFiles, MAX_CONCURRENT_READS, async (filePath) => {
+ try {
+ const stats = await stat(filePath);
+ const expected = watchFilesMetadata[filePath];
+ if (!expected) {
+ return false;
+ }
+
+ // 1. Fast Path: size and mtime match
+ if (stats.size === expected.size && stats.mtimeMs === expected.mtimeMs) {
+ return true;
+ }
+
+ // 2. Target File Path: content hash was already verified by cacheKey lookup, heal metadata if mtime changed
+ if (targetFilePath && filePath === targetFilePath) {
+ watchFilesMetadata[filePath] = {
+ ...expected,
+ mtimeMs: stats.mtimeMs,
+ size: stats.size,
+ };
+ healed = true;
+
+ return true;
+ }
+
+ // 3. Slow Path for dependencies: content hash fallback
+ const currentContent = await readFile(filePath);
+ const currentHash = calculateHash(currentContent);
+ if (currentHash === expected.hash) {
+ // Heal cache entry with new metadata
+ watchFilesMetadata[filePath] = {
+ ...expected,
+ mtimeMs: stats.mtimeMs,
+ size: stats.size,
+ };
+ healed = true;
+
+ return true;
+ }
+
+ return false;
+ } catch {
+ return false;
+ }
+ });
+
+ if (isValidResults.some((isValid) => !isValid)) {
+ return false;
+ }
+
+ if (healed) {
+ try {
+ await store.put(cacheKey, cached);
+ } catch {
+ // Ignore errors writing healed entries
+ }
+ }
+
+ return true;
+}
+
+/**
+ * Computes metadata (content hashes, mtime, size) for an array of watch file paths.
+ * Processes files with a sliding worker pool of 16 concurrent operations.
+ */
+async function computeMetadataForWatchFiles(
+ watchFiles: string[],
+ knownContents?: Map,
+): Promise> {
+ const watchFilesMetadata: Record = {};
+
+ await mapConcurrent(watchFiles, MAX_CONCURRENT_READS, async (filePath) => {
+ try {
+ const knownContent = knownContents?.get(filePath);
+ const [content, stats] = await Promise.all([
+ knownContent !== undefined ? knownContent : readFile(filePath),
+ stat(filePath),
+ ]);
+ const hash = calculateHash(content);
+ watchFilesMetadata[filePath] = {
+ hash,
+ mtimeMs: stats.mtimeMs,
+ size: stats.size,
+ };
+ } catch {
+ // Ignore unreadable files
+ }
+ });
+
+ return watchFilesMetadata;
+}
+
+export class PersistentLoadResultCache implements LoadResultCache {
+ private readonly memoryCache = new MemoryLoadResultCache();
+
+ constructor(
+ private readonly persistentStore?: PersistentCacheStore,
+ private readonly globalConfigHash: string = '',
+ ) {}
+
+ /**
+ * Retrieves a load result from cache.
+ * Checks L1 memory cache first for immediate watch-mode speed, falling back to L2 persistent disk
+ * store on L1 cache miss. L2 persistent cache entries are validated against dependency metadata.
+ */
+ async get(path: string): Promise {
+ // 1. Check L1 Memory Cache
+ const memoryResult = this.memoryCache.get(path);
+ if (memoryResult) {
+ return memoryResult;
+ }
+
+ if (!this.persistentStore) {
+ return undefined;
+ }
+
+ // 2. Check L2 Persistent Disk Cache
+ let content: string | Uint8Array = '';
+ const filePath = extractDiskFilePath(path);
+ if (filePath) {
+ try {
+ content = await readFile(filePath);
+ } catch {
+ return undefined;
+ }
+ }
+
+ const cacheKey = calculateCacheKey(this.globalConfigHash, path, content);
+ const cached = await this.persistentStore.get(cacheKey);
+
+ if (
+ cached &&
+ (await validateAndHealCacheEntry(
+ cached.watchFilesMetadata,
+ this.persistentStore,
+ cacheKey,
+ cached,
+ filePath,
+ ))
+ ) {
+ const result: OnLoadResult = {
+ contents: cached.contents,
+ loader: cached.loader,
+ watchFiles: cached.watchFiles,
+ watchDirs: cached.watchDirs,
+ warnings: cached.warnings,
+ errors: cached.errors,
+ };
+
+ // Populate L1 Memory Cache for subsequent lookups
+ await this.memoryCache.put(path, result);
+
+ return result;
+ }
+
+ return undefined;
+ }
+
+ /**
+ * Stores a load result in both L1 memory cache and L2 persistent disk store.
+ */
+ async put(path: string, result: OnLoadResult): Promise {
+ await this.memoryCache.put(path, result);
+
+ // Persist to L2 store if persistentStore is configured and contents exist (including empty strings/buffers)
+ if (this.persistentStore && result.contents !== undefined) {
+ let content: string | Uint8Array = '';
+ const filePath = extractDiskFilePath(path);
+ if (filePath) {
+ try {
+ content = await readFile(filePath);
+ } catch {
+ // Skip L2 persistent store if target disk file cannot be read
+ return;
+ }
+ }
+
+ const cacheKey = calculateCacheKey(this.globalConfigHash, path, content);
+
+ // Reuse the target file's pre-read content buffer to avoid redundant disk reads (readFile)
+ // during dependency watch file metadata computation.
+ const knownContents = filePath
+ ? new Map([[filePath, content]])
+ : undefined;
+ const watchFilesMetadata = await computeMetadataForWatchFiles(
+ result.watchFiles ?? [],
+ knownContents,
+ );
+
+ await this.persistentStore.put(cacheKey, {
+ contents: result.contents,
+ loader: result.loader,
+ watchFiles: result.watchFiles ?? [],
+ watchDirs: result.watchDirs,
+ watchFilesMetadata,
+ warnings: result.warnings,
+ errors: result.errors,
+ });
+ }
+ }
+
+ /**
+ * Invalidates cached entries affected by a modified dependency file during watch mode.
+ *
+ * Note: Invalidation of L1 memory cache is sufficient for active watch mode.
+ * Cross-process/cold start stale entries in L2 persistent store are automatically handled
+ * during `get()` via dependency metadata verification (`validateAndHealCacheEntry`).
+ */
+ invalidate(path: string): boolean {
+ return this.memoryCache.invalidate(path);
+ }
+
+ get watchFiles(): ReadonlyArray {
+ return this.memoryCache.watchFiles;
+ }
+}
diff --git a/packages/angular/build/src/tools/esbuild/persistent-load-result-cache_spec.ts b/packages/angular/build/src/tools/esbuild/persistent-load-result-cache_spec.ts
new file mode 100644
index 000000000000..f8ecc0611818
--- /dev/null
+++ b/packages/angular/build/src/tools/esbuild/persistent-load-result-cache_spec.ts
@@ -0,0 +1,263 @@
+/**
+ * @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 type { OnLoadResult } from 'esbuild';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import { initializeHash } from '../../utils/hash';
+import type { Cache as PersistentCacheStore } from './cache';
+import {
+ type CachedLoadResultEntry,
+ PersistentLoadResultCache,
+ extractDiskFilePath,
+} from './persistent-load-result-cache';
+
+describe('extractDiskFilePath', () => {
+ it('should extract disk file path for file: URIs', () => {
+ const filePath = '/Users/test/project/file.js';
+ expect(extractDiskFilePath(`file://${filePath}`)).toBe(filePath);
+ });
+
+ it('should extract disk file path for custom plugin namespaces', () => {
+ const filePath = '/Users/test/project/file.js';
+ expect(extractDiskFilePath(`sass:${filePath}`)).toBe(filePath);
+ });
+
+ it('should not strip Windows drive letter as a namespace prefix', () => {
+ const winPath = 'C:/Users/test/project/file.js';
+ expect(extractDiskFilePath(winPath)).not.toBe('/Users/test/project/file.js');
+ });
+});
+
+describe('PersistentLoadResultCache', () => {
+ let mockStore: Map;
+ let persistentStore: PersistentCacheStore;
+ let tmpDir: string;
+ let file1: string;
+
+ beforeAll(async () => {
+ await initializeHash();
+ });
+
+ beforeEach(() => {
+ tmpDir = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'persistent-cache-test-'));
+ file1 = path.join(tmpDir, 'test.js');
+ fs.writeFileSync(file1, 'console.log("hello");');
+
+ mockStore = new Map();
+ persistentStore = {
+ async get(key: string) {
+ return mockStore.get(key);
+ },
+ async put(key: string, value: CachedLoadResultEntry) {
+ mockStore.set(key, value);
+ },
+ } as unknown as PersistentCacheStore;
+ });
+
+ afterEach(() => {
+ fs.rmSync(tmpDir, { recursive: true, force: true });
+ });
+
+ it('should return undefined on L1 and L2 cache miss', async () => {
+ const cache = new PersistentLoadResultCache(persistentStore, 'global-hash');
+ const result = await cache.get(file1);
+ expect(result).toBeUndefined();
+ });
+
+ it('should hit L2 persistent store and return cached output when dependencies are valid', async () => {
+ const cache = new PersistentLoadResultCache(persistentStore, 'global-hash');
+ const sampleResult: OnLoadResult = {
+ contents: 'console.log("hello");',
+ loader: 'js',
+ watchFiles: [file1],
+ };
+
+ await cache.put(file1, sampleResult);
+
+ // Create a second cache instance (simulating cold start)
+ const coldCache = new PersistentLoadResultCache(persistentStore, 'global-hash');
+ const hit = await coldCache.get(file1);
+
+ expect(hit).toBeDefined();
+ expect(hit?.contents).toBe('console.log("hello");');
+ expect(hit?.loader).toBe('js');
+ });
+
+ it('should hit L2 persistent store and return cached output for empty file contents', async () => {
+ const cache = new PersistentLoadResultCache(persistentStore, 'global-hash');
+ const sampleResult: OnLoadResult = {
+ contents: '',
+ loader: 'css',
+ watchFiles: [file1],
+ };
+
+ await cache.put(file1, sampleResult);
+
+ // Create a second cache instance (simulating cold start)
+ const coldCache = new PersistentLoadResultCache(persistentStore, 'global-hash');
+ const hit = await coldCache.get(file1);
+
+ expect(hit).toBeDefined();
+ expect(hit?.contents).toBe('');
+ expect(hit?.loader).toBe('css');
+ });
+
+ it('should preserve watchDirs when hitting L2 persistent store', async () => {
+ const cache = new PersistentLoadResultCache(persistentStore, 'global-hash');
+ const sampleResult: OnLoadResult = {
+ contents: 'console.log("hello");',
+ loader: 'js',
+ watchFiles: [file1],
+ watchDirs: [tmpDir],
+ };
+
+ await cache.put(file1, sampleResult);
+
+ const coldCache = new PersistentLoadResultCache(persistentStore, 'global-hash');
+ const hit = await coldCache.get(file1);
+
+ expect(hit).toBeDefined();
+ expect(hit?.watchDirs).toEqual([tmpDir]);
+ });
+
+ it('should hit L2 persistent store for custom plugin namespaces backed by disk files', async () => {
+ const cache = new PersistentLoadResultCache(persistentStore, 'global-hash');
+ const customPath = `sass:${file1}`;
+ const sampleResult: OnLoadResult = {
+ contents: '.btn { color: red; }',
+ loader: 'css',
+ watchFiles: [file1],
+ };
+
+ await cache.put(customPath, sampleResult);
+
+ const coldCache = new PersistentLoadResultCache(persistentStore, 'global-hash');
+ const hit = await coldCache.get(customPath);
+
+ expect(hit).toBeDefined();
+ expect(hit?.contents).toBe('.btn { color: red; }');
+ expect(hit?.loader).toBe('css');
+ });
+
+ it('should hit L2 persistent store for virtual modules without disk representation', async () => {
+ const cache = new PersistentLoadResultCache(persistentStore, 'global-hash');
+ const virtualPath = 'angular:styles/component:css;0;data';
+ const sampleResult: OnLoadResult = {
+ contents: 'h1 { margin: 0; }',
+ loader: 'css',
+ watchFiles: [file1],
+ };
+
+ await cache.put(virtualPath, sampleResult);
+
+ const coldCache = new PersistentLoadResultCache(persistentStore, 'global-hash');
+ const hit = await coldCache.get(virtualPath);
+
+ expect(hit).toBeDefined();
+ expect(hit?.contents).toBe('h1 { margin: 0; }');
+ expect(hit?.loader).toBe('css');
+ });
+
+ it('should invalidate L2 persistent cache hit if a watch dependency file is modified', async () => {
+ const cache = new PersistentLoadResultCache(persistentStore, 'global-hash');
+ const sampleResult: OnLoadResult = {
+ contents: 'console.log("hello");',
+ loader: 'js',
+ watchFiles: [file1],
+ };
+
+ await cache.put(file1, sampleResult);
+
+ // Modify dependency file content
+ fs.writeFileSync(file1, 'console.log("world");');
+
+ const coldCache = new PersistentLoadResultCache(persistentStore, 'global-hash');
+ const hit = await coldCache.get(file1);
+
+ expect(hit).toBeUndefined();
+ });
+
+ it('should heal the cache entry with new metadata if content hash is still valid after mtime changes', async () => {
+ const cache = new PersistentLoadResultCache(persistentStore, 'global-hash');
+ const sampleResult: OnLoadResult = {
+ contents: 'console.log("hello");',
+ loader: 'js',
+ watchFiles: [file1],
+ };
+
+ await cache.put(file1, sampleResult);
+
+ // Retrieve the cache key from store
+ const keys = Array.from(mockStore.keys());
+ expect(keys.length).toBe(1);
+ const cacheKey = keys[0];
+
+ const initialEntry = mockStore.get(cacheKey);
+ const initialMtime = initialEntry?.watchFilesMetadata[file1].mtimeMs;
+ expect(initialMtime).toBeDefined();
+
+ // Artificially change file modification time without changing content
+ const futureTime = new Date(Date.now() + 50000);
+ fs.utimesSync(file1, futureTime, futureTime);
+
+ const statsAfter = fs.statSync(file1);
+ expect(statsAfter.mtimeMs).not.toEqual(initialMtime as number);
+
+ // Query cold cache to trigger fallback content hash check and healing
+ const coldCache = new PersistentLoadResultCache(persistentStore, 'global-hash');
+ const hit = await coldCache.get(file1);
+
+ expect(hit).toBeDefined();
+ expect(hit?.contents).toBe('console.log("hello");');
+
+ // Verify metadata was healed in-place in the persistent store
+ const healedEntry = mockStore.get(cacheKey);
+ expect(healedEntry?.watchFilesMetadata[file1].mtimeMs).toEqual(statsAfter.mtimeMs);
+ });
+
+ it('should safely handle corrupted cache entries with missing watchFilesMetadata', async () => {
+ const cache = new PersistentLoadResultCache(persistentStore, 'global-hash');
+ const sampleResult: OnLoadResult = {
+ contents: 'console.log("hello");',
+ loader: 'js',
+ watchFiles: [file1],
+ };
+
+ await cache.put(file1, sampleResult);
+
+ // Corrupt entry in store by removing watchFilesMetadata
+ const keys = Array.from(mockStore.keys());
+ expect(keys.length).toBe(1);
+ const entry = mockStore.get(keys[0]);
+ expect(entry).toBeDefined();
+ if (entry) {
+ delete (entry as Partial).watchFilesMetadata;
+ }
+
+ const coldCache = new PersistentLoadResultCache(persistentStore, 'global-hash');
+ const hit = await coldCache.get(file1);
+
+ expect(hit).toBeUndefined();
+ });
+
+ it('should skip L2 store put if target file cannot be read from disk', async () => {
+ const cache = new PersistentLoadResultCache(persistentStore, 'global-hash');
+ const nonExistentFile = path.join(tmpDir, 'non-existent.js');
+ const sampleResult: OnLoadResult = {
+ contents: 'console.log("missing");',
+ loader: 'js',
+ watchFiles: [],
+ };
+
+ await cache.put(nonExistentFile, sampleResult);
+
+ expect(mockStore.size).toBe(0);
+ });
+});
diff --git a/packages/angular/build/src/tools/esbuild/profiling.ts b/packages/angular/build/src/tools/esbuild/profiling.ts
index 2e67a4cb27f2..80fd26c32d0e 100644
--- a/packages/angular/build/src/tools/esbuild/profiling.ts
+++ b/packages/angular/build/src/tools/esbuild/profiling.ts
@@ -14,6 +14,32 @@ export function resetCumulativeDurations(): void {
cumulativeDurations?.clear();
}
+export function getAndClearCumulativeDurations(): Record | undefined {
+ if (!cumulativeDurations || cumulativeDurations.size === 0) {
+ return undefined;
+ }
+
+ const data = Object.fromEntries(cumulativeDurations);
+
+ cumulativeDurations.clear();
+
+ return data;
+}
+
+export function mergeCumulativeDurations(data: Record): void {
+ cumulativeDurations ??= new Map();
+
+ for (const [name, durations] of Object.entries(data)) {
+ let existing = cumulativeDurations.get(name);
+ if (!existing) {
+ existing = [];
+ cumulativeDurations.set(name, existing);
+ }
+
+ existing.push(...durations);
+ }
+}
+
export function logCumulativeDurations(): void {
if (!debugPerformance || !cumulativeDurations) {
return;
diff --git a/packages/angular/build/src/tools/esbuild/sqlite-cache-store.ts b/packages/angular/build/src/tools/esbuild/sqlite-cache-store.ts
new file mode 100644
index 000000000000..4f50515749df
--- /dev/null
+++ b/packages/angular/build/src/tools/esbuild/sqlite-cache-store.ts
@@ -0,0 +1,169 @@
+/**
+ * @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 { DatabaseSync, StatementSync } from 'node:sqlite';
+import { Cache, PersistentCacheStore } from './cache';
+
+export class SqliteCacheStore implements PersistentCacheStore {
+ #db: DatabaseSync | undefined;
+ #getStmt: StatementSync | undefined;
+ #hasStmt: StatementSync | undefined;
+ #setStmt: StatementSync | undefined;
+ #updateAccessedStmt: StatementSync | undefined;
+ readonly #pendingAccessedKeys = new Set();
+ #flushTimeout: NodeJS.Timeout | undefined;
+
+ constructor(
+ readonly cachePath: string,
+ private readonly maxPayloadSize = 1024 * 1024 * 1024,
+ private readonly ttlDays = 14,
+ ) {}
+
+ #ensureDb(): DatabaseSync {
+ if (!this.#db) {
+ this.#db = new DatabaseSync(this.cachePath);
+ // Optimize SQLite for cache usage
+ this.#db.exec('PRAGMA auto_vacuum = FULL;');
+ this.#db.exec('PRAGMA journal_mode = WAL;');
+ this.#db.exec('PRAGMA synchronous = NORMAL;');
+ this.#db.exec('PRAGMA busy_timeout = 5000;');
+ this.#db.exec('PRAGMA temp_store = MEMORY;');
+ this.#db.exec('PRAGMA mmap_size = 268435456;');
+ this.#db.exec(
+ 'CREATE TABLE IF NOT EXISTS cache (key TEXT PRIMARY KEY, value TEXT, last_accessed INTEGER NOT NULL) WITHOUT ROWID;',
+ );
+
+ this.#getStmt = this.#db.prepare('SELECT value FROM cache WHERE key = ?');
+ this.#hasStmt = this.#db.prepare('SELECT 1 FROM cache WHERE key = ?');
+ this.#setStmt = this.#db.prepare(
+ 'INSERT OR REPLACE INTO cache (key, value, last_accessed) VALUES (?, ?, unixepoch())',
+ );
+ this.#updateAccessedStmt = this.#db.prepare(
+ 'UPDATE cache SET last_accessed = unixepoch() WHERE key = ?',
+ );
+ }
+
+ return this.#db;
+ }
+
+ #queueAccessUpdate(key: string): void {
+ this.#pendingAccessedKeys.add(key);
+
+ if (this.#pendingAccessedKeys.size >= 100) {
+ this.#flushAccessUpdates();
+ } else if (!this.#flushTimeout) {
+ this.#flushTimeout = setTimeout(() => this.#flushAccessUpdates(), 500);
+ this.#flushTimeout.unref?.();
+ }
+ }
+
+ #flushAccessUpdates(): void {
+ if (this.#flushTimeout) {
+ clearTimeout(this.#flushTimeout);
+ this.#flushTimeout = undefined;
+ }
+
+ if (!this.#db || this.#pendingAccessedKeys.size === 0 || !this.#updateAccessedStmt) {
+ return;
+ }
+
+ try {
+ this.#db.exec('BEGIN IMMEDIATE TRANSACTION;');
+ for (const key of this.#pendingAccessedKeys) {
+ this.#updateAccessedStmt.run(key);
+ }
+ this.#db.exec('COMMIT;');
+ } catch {
+ try {
+ this.#db.exec('ROLLBACK;');
+ } catch {
+ // Ignore rollback errors if transaction was not active
+ }
+ } finally {
+ this.#pendingAccessedKeys.clear();
+ }
+ }
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ async get(key: string): Promise {
+ this.#ensureDb();
+ const row = this.#getStmt?.get(key) as { value: string } | undefined;
+
+ if (row) {
+ this.#queueAccessUpdate(key);
+
+ try {
+ return JSON.parse(row.value);
+ } catch {
+ return undefined;
+ }
+ }
+
+ return undefined;
+ }
+
+ has(key: string): boolean {
+ this.#ensureDb();
+
+ return !!this.#hasStmt?.get(key);
+ }
+
+ async set(key: string, value: unknown): Promise {
+ this.#ensureDb();
+ this.#pendingAccessedKeys.delete(key);
+ this.#setStmt?.run(key, JSON.stringify(value));
+
+ return this;
+ }
+
+ createCache(namespace: string): Cache {
+ return new Cache(this, namespace);
+ }
+
+ close(): void {
+ if (this.#db) {
+ try {
+ // Flush any pending access updates in one transaction before pruning
+ this.#flushAccessUpdates();
+
+ // 1. Delete items older than N days
+ this.#db
+ .prepare("DELETE FROM cache WHERE last_accessed < unixepoch('now', ?);")
+ .run(`-${this.ttlDays} days`);
+
+ // 2. Prune oldest items if payload exceeds maxPayloadSize
+ const pruneStmt = this.#db.prepare(`
+ DELETE FROM cache WHERE key IN (
+ SELECT key FROM (
+ SELECT key,
+ sum(length(key) + length(value)) OVER (ORDER BY last_accessed DESC, key DESC) as running_size
+ FROM cache
+ ) WHERE running_size > ?
+ );
+ `);
+ pruneStmt.run(this.maxPayloadSize);
+ } catch {
+ // Pruning errors should not block build success
+ } finally {
+ if (this.#flushTimeout) {
+ clearTimeout(this.#flushTimeout);
+ this.#flushTimeout = undefined;
+ }
+ this.#pendingAccessedKeys.clear();
+
+ this.#getStmt = undefined;
+ this.#hasStmt = undefined;
+ this.#setStmt = undefined;
+ this.#updateAccessedStmt = undefined;
+
+ this.#db.close();
+ this.#db = undefined;
+ }
+ }
+ }
+}
diff --git a/packages/angular/build/src/tools/esbuild/sqlite-cache-store_spec.ts b/packages/angular/build/src/tools/esbuild/sqlite-cache-store_spec.ts
new file mode 100644
index 000000000000..679bff21de20
--- /dev/null
+++ b/packages/angular/build/src/tools/esbuild/sqlite-cache-store_spec.ts
@@ -0,0 +1,178 @@
+/**
+ * @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 { promises as fs } from 'node:fs';
+import { join } from 'node:path';
+import { SqliteCacheStore } from './sqlite-cache-store';
+
+describe('SqliteCacheStore', () => {
+ let tempDir: string;
+ let cachePath: string;
+ let store: SqliteCacheStore;
+
+ beforeEach(async () => {
+ // Create a temporary directory in the workspace for testing
+ tempDir = join(__dirname, `sqlite-test-temp-${Date.now()}`);
+ await fs.mkdir(tempDir, { recursive: true });
+ cachePath = join(tempDir, 'test-cache.db');
+ store = new SqliteCacheStore(cachePath);
+ });
+
+ afterEach(async () => {
+ store.close();
+ await fs.rm(tempDir, { recursive: true, force: true });
+ });
+
+ it('should store and retrieve a value', async () => {
+ const data = { foo: 'bar', list: [1, 2, 3] };
+ await store.set('test-key', data);
+
+ const result = await store.get('test-key');
+ expect(result).toEqual(data);
+ });
+
+ it('should return undefined for non-existent key', async () => {
+ const result = await store.get('missing-key');
+ expect(result).toBeUndefined();
+ });
+
+ it('should correctly report existence of a key', async () => {
+ expect(store.has('exist-key')).toBeFalse();
+
+ await store.set('exist-key', 'value');
+ expect(store.has('exist-key')).toBeTrue();
+ });
+
+ it('should overwrite values for existing keys', async () => {
+ await store.set('overwrite-key', 'initial');
+ await store.set('overwrite-key', 'updated');
+
+ const result = await store.get('overwrite-key');
+ expect(result).toBe('updated');
+ });
+
+ it('should prune items older than TTL on close', async () => {
+ // Write two items
+ await store.set('new-key', 'new-val');
+ await store.set('old-key', 'old-val');
+
+ // Close the store so we can modify the DB safely
+ store.close();
+
+ // Directly open database to update timestamp of 'old-key' to 15 days ago
+ const { DatabaseSync } = await import('node:sqlite');
+ const directDb = new DatabaseSync(cachePath);
+ directDb
+ .prepare('UPDATE cache SET last_accessed = unixepoch() - 15 * 24 * 3600 WHERE key = ?')
+ .run('old-key');
+ directDb.close();
+
+ // Reopen store with a 14-day TTL, access it to open connection, then close to trigger pruning
+ const pruneStore = new SqliteCacheStore(cachePath, undefined, 14);
+ expect(pruneStore.has('new-key')).toBeTrue();
+ pruneStore.close();
+
+ // Verify 'old-key' is gone but 'new-key' remains
+ const checkStore = new SqliteCacheStore(cachePath);
+ expect(checkStore.has('old-key')).toBeFalse();
+ expect(checkStore.has('new-key')).toBeTrue();
+ checkStore.close();
+ });
+
+ it('should prune oldest items when total payload size exceeds maximum on close', async () => {
+ // Close the default store so we can instantiate one with a small limit
+ store.close();
+
+ // Create a store with a tiny size limit (e.g. 25 bytes)
+ // Keys 'k1', 'k2', 'k3' are small (each is 10 bytes: key + JSON.stringify(value)).
+ // Total size of k1 + k2 + k3 is 30 bytes, which exceeds the 25 bytes limit.
+ const sizeStore = new SqliteCacheStore(cachePath, 25);
+
+ // Set k1, then k2, then k3.
+ // Order of inserts: k1 (oldest), k2 (middle), k3 (newest)
+ await sizeStore.set('k1', 'value1');
+ await sizeStore.set('k2', 'value2');
+ await sizeStore.set('k3', 'value3');
+
+ // Close sizeStore to trigger pruning
+ sizeStore.close();
+
+ // Reopen to check which keys were kept
+ const checkStore = new SqliteCacheStore(cachePath);
+ // k3 (newest) and k2 (middle) should be kept (~20 bytes total)
+ // k1 (oldest) should be pruned to get under 25 bytes.
+ expect(checkStore.has('k3')).toBeTrue();
+ expect(checkStore.has('k2')).toBeTrue();
+ expect(checkStore.has('k1')).toBeFalse();
+ checkStore.close();
+ });
+
+ describe('NG_BUILD_CACHE_STORE env variable option', () => {
+ it('should force SQLite when NG_BUILD_CACHE_STORE=sqlite', () => {
+ const code = `
+ (async () => {
+ const { createPersistentCacheStore } = await import('./cache.js');
+ const { SqliteCacheStore } = await import('./sqlite-cache-store.js');
+ const store = await createPersistentCacheStore('dummy-sqlite-env');
+ if (!(store instanceof SqliteCacheStore)) {
+ console.error('Expected SqliteCacheStore, got:', store.constructor.name);
+ process.exit(1);
+ }
+ })().catch(err => {
+ console.error(err);
+ process.exit(2);
+ });
+ `;
+ const { execFileSync } = require('node:child_process');
+ execFileSync(process.execPath, ['--input-type=module', '-e', code], {
+ cwd: __dirname,
+ env: {
+ ...process.env,
+ NG_BUILD_CACHE_STORE: 'sqlite',
+ },
+ });
+ });
+
+ it('should force LMDB when NG_BUILD_CACHE_STORE=lmdb', () => {
+ const code = `
+ (async () => {
+ const { createPersistentCacheStore } = await import('./cache.js');
+ const { LmdbCacheStore } = await import('./lmdb-cache-store.js');
+ const store = await createPersistentCacheStore('dummy-lmdb-env');
+ if (!(store instanceof LmdbCacheStore)) {
+ console.error('Expected LmdbCacheStore, got:', store.constructor.name);
+ process.exit(1);
+ }
+ })().catch(err => {
+ console.error(err);
+ process.exit(2);
+ });
+ `;
+ const { execFileSync } = require('node:child_process');
+ try {
+ execFileSync(process.execPath, ['--input-type=module', '-e', code], {
+ cwd: __dirname,
+ env: {
+ ...process.env,
+ NG_BUILD_CACHE_STORE: 'lmdb',
+ },
+ });
+ } catch (e) {
+ if (e && typeof e === 'object' && 'message' in e) {
+ const error = e as { message: string; stderr?: Buffer };
+ const output = error.stderr?.toString() || error.message;
+ if (!output.includes('Unable to initialize JavaScript cache storage')) {
+ throw e;
+ }
+ } else {
+ throw e;
+ }
+ }
+ });
+ });
+});
diff --git a/packages/angular/build/src/tools/esbuild/stylesheets/stylesheet-cache-key.ts b/packages/angular/build/src/tools/esbuild/stylesheets/stylesheet-cache-key.ts
new file mode 100644
index 000000000000..22397ab189b3
--- /dev/null
+++ b/packages/angular/build/src/tools/esbuild/stylesheets/stylesheet-cache-key.ts
@@ -0,0 +1,68 @@
+/**
+ * @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 { calculateHash } from '../../../utils/hash';
+import type { BundleStylesheetOptions } from './bundle-options';
+
+/**
+ * Generates a global hash based on all build options that affect stylesheet compilation.
+ *
+ * IMPORTANT: This hash acts as the root cache key prefix for all persistent stylesheet cache
+ * entries. Any change in the values hashed below will invalidate all cached stylesheet outputs
+ * across the entire application.
+ *
+ * Included build options and their rationale:
+ * - `optimization`: Affects CSS minification, dead code elimination, and whitespace stripping.
+ * - `sourcemap`: Toggles inline/external source map generation and comment insertion.
+ * - `sourcesContent`: Toggles embedding original source content inside generated source maps.
+ * - `includePaths`: Changes Sass/Less `@import` and `@use` path resolution search directories.
+ * - `sassOptions`: Changes Sass compiler deprecations and behavior flags.
+ * - `target`: Affects CSS property lowering and browser vendor prefixing in esbuild.
+ * - `publicPath`: Affects relative asset URL rewriting (`url('...')`) inside CSS output.
+ * - `outputNames`: Affects asset output filename hashing schemes.
+ * - `inlineFonts`: Controls whether external web font `@import` / `` directives are inlined.
+ * - `preserveSymlinks`: Controls symlink realpath resolution in monorepos/pnpm workspace packages.
+ * - `externalDependencies`: Controls which CSS modules/urls are excluded from bundling.
+ * - `postcssConfig`: Path to custom PostCSS configuration file.
+ * - `tailwindConfig`: Path to Tailwind CSS configuration file.
+ * - `packageVersion`: Invalidates cache across `@angular/build` compiler toolchain updates.
+ *
+ * @note Maintainers: If any new build option is added to `BundleStylesheetOptions` or `@angular/build`
+ * that alters generated stylesheet code or asset outputs, it MUST be added to this hash object.
+ */
+export function calculateGlobalStylesheetConfigHash(
+ options: BundleStylesheetOptions,
+ packageVersion: string = '',
+): string {
+ return calculateHash(
+ JSON.stringify({
+ optimization: options.optimization,
+ sourcemap: options.sourcemap,
+ sourcesContent: options.sourcesContent,
+ includePaths: options.includePaths,
+ sassOptions: options.sass
+ ? {
+ futureDeprecations: options.sass.futureDeprecations,
+ fatalDeprecations: options.sass.fatalDeprecations,
+ silenceDeprecations: options.sass.silenceDeprecations,
+ }
+ : undefined,
+ target: options.target,
+ publicPath: options.publicPath,
+ outputNames: options.outputNames,
+ inlineFonts: options.inlineFonts,
+ preserveSymlinks: options.preserveSymlinks,
+ externalDependencies: options.externalDependencies,
+ postcssConfig: options.postcssConfiguration?.configPath
+ ? options.postcssConfiguration.configPath
+ : '',
+ tailwindConfig: options.tailwindConfiguration?.file ? options.tailwindConfiguration.file : '',
+ packageVersion,
+ }),
+ );
+}
diff --git a/packages/angular/build/src/tools/esbuild/stylesheets/stylesheet-cache-key_spec.ts b/packages/angular/build/src/tools/esbuild/stylesheets/stylesheet-cache-key_spec.ts
new file mode 100644
index 000000000000..c524a2d0c36b
--- /dev/null
+++ b/packages/angular/build/src/tools/esbuild/stylesheets/stylesheet-cache-key_spec.ts
@@ -0,0 +1,74 @@
+/**
+ * @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 { initializeHash } from '../../../utils/hash';
+import type { BundleStylesheetOptions } from './bundle-options';
+import { calculateGlobalStylesheetConfigHash } from './stylesheet-cache-key';
+
+describe('Stylesheet Global Config Hash', () => {
+ beforeAll(async () => {
+ await initializeHash();
+ });
+
+ const baseOptions: BundleStylesheetOptions = {
+ workspaceRoot: '/root',
+ optimization: true,
+ inlineFonts: false,
+ sourcemap: true,
+ outputNames: { bundles: 'styles', media: 'media' },
+ target: ['chrome100'],
+ cacheOptions: { enabled: true, path: '/cache', basePath: '/root' },
+ };
+
+ describe('calculateGlobalStylesheetConfigHash', () => {
+ it('should generate consistent hashes for identical options', () => {
+ const hash1 = calculateGlobalStylesheetConfigHash(baseOptions, '1.0.0');
+ const hash2 = calculateGlobalStylesheetConfigHash({ ...baseOptions }, '1.0.0');
+ expect(hash1).toBe(hash2);
+ });
+
+ it('should produce different hashes when optimization changes', () => {
+ const hash1 = calculateGlobalStylesheetConfigHash(baseOptions, '1.0.0');
+ const hash2 = calculateGlobalStylesheetConfigHash(
+ { ...baseOptions, optimization: false },
+ '1.0.0',
+ );
+ expect(hash1).not.toBe(hash2);
+ });
+
+ it('should produce different hashes when sourcemap options change', () => {
+ const hash1 = calculateGlobalStylesheetConfigHash(baseOptions, '1.0.0');
+ const hash2 = calculateGlobalStylesheetConfigHash(
+ { ...baseOptions, sourcemap: false },
+ '1.0.0',
+ );
+ expect(hash1).not.toBe(hash2);
+ });
+
+ it('should produce different hashes when sourcesContent changes', () => {
+ const hash1 = calculateGlobalStylesheetConfigHash(
+ { ...baseOptions, sourcesContent: true },
+ '1.0.0',
+ );
+ const hash2 = calculateGlobalStylesheetConfigHash(
+ { ...baseOptions, sourcesContent: false },
+ '1.0.0',
+ );
+ expect(hash1).not.toBe(hash2);
+ });
+
+ it('should produce different hashes when target browsers change', () => {
+ const hash1 = calculateGlobalStylesheetConfigHash(baseOptions, '1.0.0');
+ const hash2 = calculateGlobalStylesheetConfigHash(
+ { ...baseOptions, target: ['firefox90'] },
+ '1.0.0',
+ );
+ expect(hash1).not.toBe(hash2);
+ });
+ });
+});
diff --git a/packages/angular/build/src/tools/esbuild/stylesheets/stylesheet-plugin-factory.ts b/packages/angular/build/src/tools/esbuild/stylesheets/stylesheet-plugin-factory.ts
index 78925f35835e..2c40e007350e 100644
--- a/packages/angular/build/src/tools/esbuild/stylesheets/stylesheet-plugin-factory.ts
+++ b/packages/angular/build/src/tools/esbuild/stylesheets/stylesheet-plugin-factory.ts
@@ -427,6 +427,7 @@ async function compileString(
},
},
],
+ watchFiles: error.file && error.file !== filename ? [filename, error.file] : [filename],
};
} else {
assertIsError(error);
diff --git a/packages/angular/build/src/tools/esbuild/target.ts b/packages/angular/build/src/tools/esbuild/target.ts
new file mode 100644
index 000000000000..2925905276b7
--- /dev/null
+++ b/packages/angular/build/src/tools/esbuild/target.ts
@@ -0,0 +1,116 @@
+/**
+ * @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 { coerce, compare, minVersion } from 'semver';
+
+/**
+ * Compares two target version strings.
+ *
+ * This function is used to determine the lowest version for a given browser target.
+ *
+ * @param a The first version string.
+ * @param b The second version string.
+ * @returns A negative value if `a` is lower than `b`, a positive value if `a` is higher than `b`, and 0 if they are equal.
+ */
+function compareTargetVersions(a: string, b: string): number {
+ const aVersion = coerce(a);
+ const bVersion = coerce(b);
+
+ if (!aVersion || !bVersion) {
+ return aVersion ? -1 : bVersion ? 1 : 0;
+ }
+
+ return compare(aVersion, bVersion);
+}
+
+// https://esbuild.github.io/api/#target
+const ESBUILD_SUPPORTED_BROWSERS: ReadonlySet = new Set([
+ 'chrome',
+ 'edge',
+ 'firefox',
+ 'ie',
+ 'ios',
+ 'node',
+ 'opera',
+ 'safari',
+]);
+
+/**
+ * Transform browserlists result to esbuild target.
+ *
+ * Only the lowest version for each browser is returned to avoid issues with esbuild and rolldown
+ * when multiple versions of the same target engine are specified.
+ *
+ * @see https://esbuild.github.io/api/#target
+ * @see https://github.com/evanw/esbuild/issues/4509
+ * @see https://github.com/rolldown/rolldown/issues/10633
+ */
+export function transformSupportedBrowsersToTargets(supportedBrowsers: string[]): string[] {
+ const browsers = new Map();
+
+ for (const browser of supportedBrowsers) {
+ let [browserName, version] = browser.toLowerCase().split(' ');
+ if (!browserName || !version) {
+ continue;
+ }
+
+ // browserslist uses the name `ios_saf` for iOS Safari whereas esbuild uses `ios`
+ if (browserName === 'ios_saf') {
+ browserName = 'ios';
+ }
+
+ if (!ESBUILD_SUPPORTED_BROWSERS.has(browserName)) {
+ continue;
+ }
+
+ // browserslist uses ranges `15.2-15.3` versions but only the lowest is required
+ // to perform minimum supported feature checks. esbuild also expects a single version.
+ [version] = version.split('-');
+
+ if (browserName === 'safari' && version === 'tp') {
+ // esbuild only supports numeric versions so `TP` is converted to a high number (999) since
+ // a Technology Preview (TP) of Safari is assumed to support all currently known features.
+ version = '999';
+ } else if (!version.includes('.')) {
+ // A lone major version is considered by esbuild to include all minor versions. However,
+ // browserslist does not and is also inconsistent in its `.0` version naming. For example,
+ // Safari 15.0 is named `safari 15` but Safari 16.0 is named `safari 16.0`.
+ version += '.0';
+ }
+
+ const current = browsers.get(browserName);
+ if (!current || compareTargetVersions(version, current) < 0) {
+ browsers.set(browserName, version);
+ }
+ }
+
+ return Array.from(browsers, ([browserName, version]) => browserName + version);
+}
+
+const SUPPORTED_NODE_VERSIONS = '0.0.0-ENGINES-NODE';
+
+/**
+ * Transform supported Node.js versions to esbuild target.
+ *
+ * Only the lowest Node.js version is returned to avoid issues with esbuild and rolldown
+ * when multiple versions of the same target engine are specified.
+ *
+ * @see https://esbuild.github.io/api/#target
+ * @see https://github.com/evanw/esbuild/issues/4509
+ * @see https://github.com/rolldown/rolldown/issues/10633
+ */
+export function getSupportedNodeTargets(): string[] {
+ if (SUPPORTED_NODE_VERSIONS.charAt(0) === '0') {
+ // Unlike `pkg_npm`, `ts_library` which is used to run unit tests does not support substitutions.
+ return [];
+ }
+
+ const parsed = minVersion(SUPPORTED_NODE_VERSIONS);
+
+ return parsed ? ['node' + parsed.version] : [];
+}
diff --git a/packages/angular/build/src/tools/esbuild/target_spec.ts b/packages/angular/build/src/tools/esbuild/target_spec.ts
new file mode 100644
index 000000000000..e5a375a1412b
--- /dev/null
+++ b/packages/angular/build/src/tools/esbuild/target_spec.ts
@@ -0,0 +1,80 @@
+/**
+ * @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 { getSupportedNodeTargets, transformSupportedBrowsersToTargets } from './target';
+
+describe('esbuild target', () => {
+ describe('transformSupportedBrowsersToTargets', () => {
+ it('should return the smallest version for each browser', () => {
+ const targets = transformSupportedBrowsersToTargets([
+ 'chrome 122',
+ 'chrome 120',
+ 'chrome 121',
+ 'firefox 116',
+ 'firefox 115',
+ 'safari 17.0',
+ 'safari 16.4',
+ ]);
+
+ expect(targets).toEqual(['chrome120.0', 'firefox115.0', 'safari16.4']);
+ });
+
+ it('should handle version ranges and pick the lowest version', () => {
+ const targets = transformSupportedBrowsersToTargets([
+ 'ios_saf 15.4',
+ 'ios_saf 15.2-15.3',
+ 'ios_saf 16.0',
+ ]);
+
+ expect(targets).toEqual(['ios15.2']);
+ });
+
+ it('should handle Safari TP (Technology Preview)', () => {
+ const targetsWithOlderSafari = transformSupportedBrowsersToTargets([
+ 'safari TP',
+ 'safari 16.4',
+ ]);
+ expect(targetsWithOlderSafari).toEqual(['safari16.4']);
+
+ const targetsWithOnlyTP = transformSupportedBrowsersToTargets(['safari TP']);
+ expect(targetsWithOnlyTP).toEqual(['safari999']);
+ });
+
+ it('should ignore browsers not supported by esbuild', () => {
+ const targets = transformSupportedBrowsersToTargets([
+ 'android 4.4',
+ 'samsung 22',
+ 'kaios 2.5',
+ 'chrome 115',
+ ]);
+
+ expect(targets).toEqual(['chrome115.0']);
+ });
+
+ it('should return empty array for empty supportedBrowsers', () => {
+ const targets = transformSupportedBrowsersToTargets([]);
+ expect(targets).toEqual([]);
+ });
+
+ it('should handle malformed or incomplete browser strings gracefully', () => {
+ const targets = transformSupportedBrowsersToTargets(['chrome', 'firefox ', '']);
+ expect(targets).toEqual([]);
+ });
+
+ it('should handle single major versions by appending .0', () => {
+ const targets = transformSupportedBrowsersToTargets(['chrome 120', 'edge 120']);
+ expect(targets).toEqual(['chrome120.0', 'edge120.0']);
+ });
+ });
+
+ describe('getSupportedNodeTargets', () => {
+ it('should return empty array when node versions are not stamped', () => {
+ expect(getSupportedNodeTargets()).toEqual([]);
+ });
+ });
+});
diff --git a/packages/angular/build/src/tools/esbuild/utils.ts b/packages/angular/build/src/tools/esbuild/utils.ts
index 9024a981c3f7..7d9dce4a5522 100644
--- a/packages/angular/build/src/tools/esbuild/utils.ts
+++ b/packages/angular/build/src/tools/esbuild/utils.ts
@@ -9,11 +9,9 @@
import { BuilderContext } from '@angular-devkit/architect';
import { BuildOptions, Metafile, OutputFile, formatMessages } from 'esbuild';
import { Listr } from 'listr2';
-import { createHash } from 'node:crypto';
import { basename, join } from 'node:path';
import { pathToFileURL } from 'node:url';
import { brotliCompress } from 'node:zlib';
-import { coerce } from 'semver';
import { NormalizedApplicationBuildOptions } from '../../builders/application/options';
import { OutputMode } from '../../builders/application/schema';
import { BudgetCalculatorResult } from '../../utils/bundle-calculator';
@@ -27,7 +25,7 @@ import {
import { type BuildOutputFile, BuildOutputFileType, type InitialFileRecord } from './bundler-files';
export function logBuildStats(
- metafile: Metafile,
+ metafiles: Metafile[],
outputFiles: BuildOutputFile[],
initial: Map,
budgetFailures: BudgetCalculatorResult[] | undefined,
@@ -67,12 +65,12 @@ export function logBuildStats(
}
// Skip logging external component stylesheets used for HMR
- if (metafile.outputs[file] && 'ng-component' in metafile.outputs[file]) {
+ if (metafiles.some((mf) => mf.outputs[file] && 'ng-component' in mf.outputs[file])) {
componentStyleChange = true;
continue;
}
- const name = initial.get(file)?.name ?? getChunkNameFromMetafile(metafile, file);
+ const name = initial.get(file)?.name ?? getChunkNameFromMetafile(metafiles, file);
const stat: BundleStats = {
initial: initial.has(file),
stats: [file, name ?? '-', size, estimatedTransferSizes?.get(file) ?? '-'],
@@ -110,9 +108,15 @@ export function logBuildStats(
return '';
}
-export function getChunkNameFromMetafile(metafile: Metafile, file: string): string | undefined {
- if (metafile.outputs[file]?.entryPoint) {
- return getEntryPointName(metafile.outputs[file].entryPoint);
+export function getChunkNameFromMetafile(
+ metafiles: Metafile[] | Metafile,
+ file: string,
+): string | undefined {
+ const metafileArray = Array.isArray(metafiles) ? metafiles : [metafiles];
+ for (const metafile of metafileArray) {
+ if (metafile.outputs[file]?.entryPoint) {
+ return getEntryPointName(metafile.outputs[file].entryPoint);
+ }
}
}
@@ -214,7 +218,7 @@ export async function emitFilesToDisk(
writeFileCallback: (file: T) => Promise,
): Promise {
// Write files in groups of MAX_CONCURRENT_WRITES to avoid too many open files
- for (let fileIndex = 0; fileIndex < files.length; ) {
+ for (let fileIndex = 0; fileIndex < files.length;) {
const groupMax = Math.min(fileIndex + MAX_CONCURRENT_WRITES, files.length);
const actions = [];
@@ -226,71 +230,6 @@ export async function emitFilesToDisk(
}
}
-/**
- * Transform browserlists result to esbuild target.
- * @see https://esbuild.github.io/api/#target
- */
-export function transformSupportedBrowsersToTargets(supportedBrowsers: string[]): string[] {
- const transformed: string[] = [];
-
- // https://esbuild.github.io/api/#target
- const esBuildSupportedBrowsers = new Set([
- 'chrome',
- 'edge',
- 'firefox',
- 'ie',
- 'ios',
- 'node',
- 'opera',
- 'safari',
- ]);
-
- for (const browser of supportedBrowsers) {
- let [browserName, version] = browser.toLowerCase().split(' ');
-
- // browserslist uses the name `ios_saf` for iOS Safari whereas esbuild uses `ios`
- if (browserName === 'ios_saf') {
- browserName = 'ios';
- }
-
- // browserslist uses ranges `15.2-15.3` versions but only the lowest is required
- // to perform minimum supported feature checks. esbuild also expects a single version.
- [version] = version.split('-');
-
- if (esBuildSupportedBrowsers.has(browserName)) {
- if (browserName === 'safari' && version === 'tp') {
- // esbuild only supports numeric versions so `TP` is converted to a high number (999) since
- // a Technology Preview (TP) of Safari is assumed to support all currently known features.
- version = '999';
- } else if (!version.includes('.')) {
- // A lone major version is considered by esbuild to include all minor versions. However,
- // browserslist does not and is also inconsistent in its `.0` version naming. For example,
- // Safari 15.0 is named `safari 15` but Safari 16.0 is named `safari 16.0`.
- version += '.0';
- }
-
- transformed.push(browserName + version);
- }
- }
-
- return transformed;
-}
-
-const SUPPORTED_NODE_VERSIONS = '0.0.0-ENGINES-NODE';
-
-/**
- * Transform supported Node.js versions to esbuild target.
- * @see https://esbuild.github.io/api/#target
- */
-export function getSupportedNodeTargets(): string[] {
- if (SUPPORTED_NODE_VERSIONS.charAt(0) === '0') {
- // Unlike `pkg_npm`, `ts_library` which is used to run unit tests does not support substitutions.
- return [];
- }
-
- return SUPPORTED_NODE_VERSIONS.split('||').map((v) => 'node' + coerce(v)?.version);
-}
-
interface BuildManifest {
errors: string[];
warnings: string[];
diff --git a/packages/angular/build/src/tools/esbuild/watcher.ts b/packages/angular/build/src/tools/esbuild/watcher.ts
index cf9e1d94cb87..b6e26f5c72af 100644
--- a/packages/angular/build/src/tools/esbuild/watcher.ts
+++ b/packages/angular/build/src/tools/esbuild/watcher.ts
@@ -6,7 +6,12 @@
* found in the LICENSE file at https://angular.dev/license
*/
-import WatchPack from 'watchpack';
+import type * as ParcelWatcher from '@parcel/watcher';
+import type * as Chokidar from 'chokidar';
+import * as fs from 'node:fs';
+import * as path from 'node:path';
+import picomatch from 'picomatch';
+import { toPosixPath } from '../../utils/path';
export class ChangedFiles {
readonly added = new Set();
@@ -14,7 +19,7 @@ export class ChangedFiles {
readonly removed = new Set();
get all(): string[] {
- return [...this.added, ...this.modified, ...this.removed];
+ return Array.from(new Set([...this.added, ...this.modified, ...this.removed]));
}
toDebugString(): string {
@@ -34,102 +39,581 @@ export interface BuildWatcher extends AsyncIterableIterator {
close(): Promise;
}
-export function createWatcher(options?: {
+export interface WatcherOptions {
polling?: boolean;
interval?: number;
ignored?: string[];
followSymlinks?: boolean;
-}): BuildWatcher {
- const watcher = new WatchPack({
- poll: options?.polling ? (options?.interval ?? true) : false,
- ignored: options?.ignored,
- followSymlinks: options?.followSymlinks,
- aggregateTimeout: 250,
- });
- const watchedFiles = new Set();
+ cwd?: string;
+}
+
+/**
+ * Probes the filesystem at the specified target directory to determine whether it is case-sensitive.
+ */
+function isFileSystemCaseSensitive(targetDir: string = process.cwd()): boolean {
+ try {
+ const resolved = path.resolve(targetDir);
+ if (!fs.existsSync(resolved)) {
+ return process.platform !== 'win32' && process.platform !== 'darwin';
+ }
+
+ // Invert the casing of the target directory path.
+ const altCase =
+ resolved === resolved.toLowerCase() ? resolved.toUpperCase() : resolved.toLowerCase();
+
+ // If the path contains no alphabetic characters (e.g. root '/'), invert-casing
+ // produces the exact same string. Fall back to platform-specific defaults in this case.
+ if (resolved === altCase) {
+ return process.platform !== 'win32' && process.platform !== 'darwin';
+ }
+
+ // If both the original path and the inverted-casing path exist on disk,
+ // the filesystem is case-insensitive (returns false).
+ return !fs.existsSync(altCase);
+ } catch {
+ // If an error occurs (e.g., permission denied), default to the platform-specific
+ // behavior (case-insensitive on Windows/macOS, sensitive on Linux/Unix).
+ return process.platform !== 'win32' && process.platform !== 'darwin';
+ }
+}
+
+/**
+ * Normalizes a file system path string to POSIX format (forward slashes '/')
+ * and strips trailing slashes (except root '/' or Windows drive root 'C:/').
+ */
+export function toPosixPathNormalized(pathString: string): string {
+ let posixPath = toPosixPath(pathString);
+ if (posixPath.length > 1 && posixPath.endsWith('/') && !/^[a-zA-Z]:\/$/.test(posixPath)) {
+ posixPath = posixPath.slice(0, -1);
+ }
+
+ return posixPath;
+}
+
+/**
+ * Returns a lookup key for set lookups and matching, lowercasing on case-insensitive file systems.
+ */
+function toLookupKey(posixPath: string, isCaseSensitive: boolean): string {
+ return isCaseSensitive ? posixPath : posixPath.toLowerCase();
+}
- const nextQueue: ((value?: ChangedFiles) => void)[] = [];
- let currentChangedFiles: ChangedFiles | undefined;
+/**
+ * Returns the parent directory of a normalized POSIX path, correctly handling Windows drive roots.
+ */
+export function getDirectoryPath(posixPath: string): string {
+ const lastSlash = posixPath.lastIndexOf('/');
+ if (lastSlash === -1) {
+ return '.';
+ }
+ const dir = posixPath.slice(0, lastSlash);
+ if (dir === '' || dir.endsWith(':')) {
+ return dir + '/';
+ }
+
+ return dir;
+}
+
+/**
+ * Determines whether a file path lookup key or any of its parent directories are present in watchedFiles.
+ */
+function isPathWatched(fileLookupKey: string, watchedFiles: Set): boolean {
+ if (watchedFiles.has(fileLookupKey)) {
+ return true;
+ }
+
+ let current = fileLookupKey;
+ while (true) {
+ const parent = getDirectoryPath(current);
+ if (parent === current) {
+ break;
+ }
+ if (watchedFiles.has(parent)) {
+ return true;
+ }
+ current = parent;
+ }
+
+ return false;
+}
+
+class WatcherQueue {
+ private readonly nextQueue: ((value?: ChangedFiles) => void)[] = [];
+ private currentChangedFiles: ChangedFiles | undefined;
+ private isClosed = false;
+ private timeoutId: NodeJS.Timeout | undefined;
+
+ addChange(type: 'added' | 'modified' | 'removed', file: string): void {
+ if (this.isClosed) {
+ return;
+ }
+
+ const changedFiles = (this.currentChangedFiles ??= new ChangedFiles());
+ changedFiles[type].add(file);
+ this.scheduleFlush();
+ }
- watcher.on('aggregated', (changes, removals) => {
- const changedFiles = currentChangedFiles ?? new ChangedFiles();
- for (const file of changes) {
- changedFiles.modified.add(file);
+ addChanges(
+ changes: ReadonlyArray<{ type: 'added' | 'modified' | 'removed'; file: string }>,
+ ): void {
+ if (this.isClosed || changes.length === 0) {
+ return;
}
- for (const file of removals) {
- changedFiles.removed.add(file);
+
+ const changedFiles = (this.currentChangedFiles ??= new ChangedFiles());
+ for (const { type, file } of changes) {
+ changedFiles[type].add(file);
}
+ this.scheduleFlush();
+ }
- const next = nextQueue.shift();
- if (next) {
- currentChangedFiles = undefined;
- next(changedFiles);
- } else {
- currentChangedFiles = changedFiles;
+ private scheduleFlush(): void {
+ if (this.timeoutId) {
+ clearTimeout(this.timeoutId);
}
- });
+ this.timeoutId = setTimeout(() => {
+ this.timeoutId = undefined;
+ this.flush();
+ }, 250);
+ }
+
+ private flush(): void {
+ if (
+ this.currentChangedFiles &&
+ this.currentChangedFiles.all.length > 0 &&
+ this.nextQueue.length > 0
+ ) {
+ const next = this.nextQueue.shift();
+ if (next) {
+ const result = this.currentChangedFiles;
+ this.currentChangedFiles = undefined;
+ next(result);
+ }
+ }
+ }
+
+ async next(): Promise> {
+ if (
+ this.currentChangedFiles &&
+ this.currentChangedFiles.all.length > 0 &&
+ this.nextQueue.length === 0 &&
+ !this.timeoutId
+ ) {
+ const result = { value: this.currentChangedFiles };
+ this.currentChangedFiles = undefined;
+
+ return result;
+ }
+
+ if (this.isClosed) {
+ return { done: true, value: undefined as unknown as ChangedFiles };
+ }
+
+ return new Promise((resolve) => {
+ this.nextQueue.push((value) =>
+ resolve(value ? { value } : { done: true, value: undefined as unknown as ChangedFiles }),
+ );
+ });
+ }
+
+ close(): void {
+ if (this.isClosed) {
+ return;
+ }
+
+ if (this.timeoutId) {
+ clearTimeout(this.timeoutId);
+ this.timeoutId = undefined;
+ }
+
+ this.isClosed = true;
+ this.currentChangedFiles = undefined;
+
+ let next;
+ while ((next = this.nextQueue.shift()) !== undefined) {
+ next();
+ }
+ }
+}
+
+export async function createWatcher(options?: WatcherOptions): Promise {
+ if (options?.polling) {
+ return createChokidarWatcher(options);
+ }
+
+ try {
+ const parcelWatcher = await import('@parcel/watcher');
+
+ return await createParcelWatcher(options, parcelWatcher);
+ } catch {
+ return createChokidarWatcher(options);
+ }
+}
+
+/**
+ * Checks whether a file path is located inside a parent directory.
+ *
+ * Input Expectations:
+ * - Both `file` and `dir` must be normalized POSIX-style paths (using forward slashes '/').
+ * - Both paths must share the same casing normalization (e.g., lowercased on case-insensitive file systems).
+ */
+export function isPathInside(file: string, dir: string): boolean {
+ if (file === dir) {
+ return false;
+ }
+
+ const dirWithSlash = dir.endsWith('/') ? dir : dir + '/';
+
+ return file.startsWith(dirWithSlash);
+}
+
+class ParcelExternalManager {
+ private readonly extraSubscriptions = new Map();
+ private readonly pendingSubscriptions = new Map<
+ string,
+ Promise
+ >();
+ private readonly externalDirFiles = new Map }>();
+
+ constructor(
+ private readonly parcelWatcher: typeof ParcelWatcher,
+ private readonly options: WatcherOptions | undefined,
+ private readonly rootDirLookupKey: string,
+ private readonly handleEvents: (events: ParcelWatcher.Event[]) => void,
+ ) {}
+
+ async ensureWatched(posixPath: string, lookupKey: string): Promise {
+ if (isPathInside(lookupKey, this.rootDirLookupKey) || lookupKey === this.rootDirLookupKey) {
+ return;
+ }
+
+ const dirPath = getDirectoryPath(posixPath);
+ const dirKey = getDirectoryPath(lookupKey);
+ let dirEntry = this.externalDirFiles.get(dirKey);
+ if (!dirEntry) {
+ dirEntry = { dirPath, files: new Set() };
+ this.externalDirFiles.set(dirKey, dirEntry);
+ }
+ dirEntry.files.add(lookupKey);
+
+ await this.ensureDirWatched(dirPath, dirKey);
+ }
+
+ removeFile(lookupKey: string): void {
+ if (isPathInside(lookupKey, this.rootDirLookupKey) || lookupKey === this.rootDirLookupKey) {
+ return;
+ }
+
+ const dirKey = getDirectoryPath(lookupKey);
+ const dirEntry = this.externalDirFiles.get(dirKey);
+ if (dirEntry) {
+ dirEntry.files.delete(lookupKey);
+ if (dirEntry.files.size === 0) {
+ this.externalDirFiles.delete(dirKey);
+ const sub = this.extraSubscriptions.get(dirKey);
+ if (sub) {
+ this.extraSubscriptions.delete(dirKey);
+ sub.unsubscribe().catch(() => {});
+
+ for (const [remainingDirKey, remainingDirEntry] of this.externalDirFiles.entries()) {
+ if (!this.isCoveredByExistingExternal(remainingDirKey)) {
+ this.ensureDirWatched(remainingDirEntry.dirPath, remainingDirKey).catch(() => {});
+ }
+ }
+ }
+ }
+ }
+ }
+
+ async close(): Promise {
+ try {
+ if (this.pendingSubscriptions.size > 0) {
+ await Promise.allSettled(Array.from(this.pendingSubscriptions.values()));
+ }
+ if (this.extraSubscriptions.size > 0) {
+ await Promise.allSettled(
+ Array.from(this.extraSubscriptions.values()).map((sub) => sub.unsubscribe()),
+ );
+ }
+ } finally {
+ this.extraSubscriptions.clear();
+ this.pendingSubscriptions.clear();
+ this.externalDirFiles.clear();
+ }
+ }
+
+ private isCoveredByExistingExternal(dirLookupKey: string): boolean {
+ for (const existingDir of this.extraSubscriptions.keys()) {
+ if (dirLookupKey === existingDir || isPathInside(dirLookupKey, existingDir)) {
+ return true;
+ }
+ }
+ for (const pendingDir of this.pendingSubscriptions.keys()) {
+ if (dirLookupKey === pendingDir || isPathInside(dirLookupKey, pendingDir)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private async ensureDirWatched(dirPath: string, dirKey: string): Promise {
+ if (this.isCoveredByExistingExternal(dirKey)) {
+ return;
+ }
+
+ const subPromise = this.parcelWatcher.subscribe(
+ dirPath,
+ (err, events) => {
+ if (!err) {
+ this.handleEvents(events);
+ }
+ },
+ {
+ ignore: this.options?.ignored,
+ },
+ );
+
+ this.pendingSubscriptions.set(dirKey, subPromise);
+
+ try {
+ const sub = await subPromise;
+ if (this.externalDirFiles.has(dirKey) && !this.isCoveredByExistingExternal(dirKey)) {
+ this.extraSubscriptions.set(dirKey, sub);
+
+ // Subsume any nested child subscriptions that are now covered by this parent subscription
+ for (const [childDir, childSub] of this.extraSubscriptions.entries()) {
+ if (childDir !== dirKey && isPathInside(childDir, dirKey)) {
+ this.extraSubscriptions.delete(childDir);
+ childSub.unsubscribe().catch(() => {});
+ }
+ }
+ } else {
+ sub.unsubscribe().catch(() => {});
+ }
+ } catch {
+ // Ignore subscription errors for missing or restricted external directories
+ } finally {
+ this.pendingSubscriptions.delete(dirKey);
+ }
+ }
+}
+
+async function createParcelWatcher(
+ options: WatcherOptions | undefined,
+ parcelWatcher: typeof ParcelWatcher,
+): Promise {
+ const watchedFiles = new Set();
+ const queue = new WatcherQueue();
+
+ const isCaseSensitive = isFileSystemCaseSensitive(options?.cwd);
+ const rootDirPosix = toPosixPathNormalized(options?.cwd ?? process.cwd());
+ const rootDirLookupKey = toLookupKey(rootDirPosix, isCaseSensitive);
+ const initTime = Date.now();
+
+ const handleEvents = (events: ParcelWatcher.Event[]) => {
+ const changes: { type: 'added' | 'modified' | 'removed'; file: string }[] = [];
+ for (const event of events) {
+ const posixPath = toPosixPathNormalized(event.path);
+ const lookupKey = toLookupKey(posixPath, isCaseSensitive);
+ if (!isPathWatched(lookupKey, watchedFiles)) {
+ continue;
+ }
+
+ if (event.type !== 'delete') {
+ const stat = fs.statSync(event.path, { throwIfNoEntry: false });
+ // Ignore historical events from before watcher initialization, but allow a 1000 ms window
+ // to account for coarse filesystem timestamp resolution (e.g., ext4/overlayfs integer second
+ // mtime truncation on Linux) where files modified during startup may have truncated .000 ms mtimes.
+ if (stat && stat.mtimeMs < initTime - 1000) {
+ continue;
+ }
+ }
+
+ const type =
+ event.type === 'create' ? 'added' : event.type === 'delete' ? 'removed' : 'modified';
+ changes.push({ type, file: event.path });
+ }
+
+ if (changes.length > 0) {
+ queue.addChanges(changes);
+ }
+ };
- return {
+ const subscription = await parcelWatcher.subscribe(
+ rootDirPosix,
+ (err, events) => {
+ if (!err) {
+ handleEvents(events);
+ }
+ },
+ {
+ ignore: options?.ignored,
+ },
+ );
+
+ const externalManager = new ParcelExternalManager(
+ parcelWatcher,
+ options,
+ rootDirLookupKey,
+ handleEvents,
+ );
+
+ const buildWatcher: BuildWatcher = {
[Symbol.asyncIterator]() {
return this;
},
- async next() {
- if (currentChangedFiles && nextQueue.length === 0) {
- const result = { value: currentChangedFiles };
- currentChangedFiles = undefined;
+ next() {
+ return queue.next();
+ },
- return result;
+ add(paths) {
+ const targets = typeof paths === 'string' ? [paths] : paths;
+ for (const file of targets) {
+ const posixPath = toPosixPathNormalized(file);
+ const lookupKey = toLookupKey(posixPath, isCaseSensitive);
+ if (!watchedFiles.has(lookupKey)) {
+ watchedFiles.add(lookupKey);
+ void externalManager.ensureWatched(posixPath, lookupKey);
+ }
}
+ },
- return new Promise((resolve) => {
- nextQueue.push((value) => resolve(value ? { value } : { done: true, value }));
- });
+ remove(paths) {
+ const targets = typeof paths === 'string' ? [paths] : paths;
+ for (const file of targets) {
+ const posixPath = toPosixPathNormalized(file);
+ const lookupKey = toLookupKey(posixPath, isCaseSensitive);
+ if (watchedFiles.delete(lookupKey)) {
+ externalManager.removeFile(lookupKey);
+ }
+ }
},
- add(paths) {
- const previousSize = watchedFiles.size;
- if (typeof paths === 'string') {
- watchedFiles.add(paths);
- } else {
- for (const file of paths) {
- watchedFiles.add(file);
+ async close() {
+ try {
+ if (subscription) {
+ await subscription.unsubscribe();
}
+ await externalManager.close();
+ } finally {
+ queue.close();
}
+ },
+ };
+
+ return buildWatcher;
+}
+
+async function createChokidarWatcher(
+ options?: WatcherOptions,
+ chokidarModule?: typeof Chokidar,
+): Promise {
+ const chokidar = chokidarModule ?? (await import('chokidar'));
+ const watchedFiles = new Set();
+ const queue = new WatcherQueue();
+
+ const rootDir = options?.cwd ?? process.cwd();
+ const isCaseSensitive = isFileSystemCaseSensitive(rootDir);
+ const rootDirPosix = toPosixPathNormalized(rootDir);
+ const rootDirLookupKey = toLookupKey(rootDirPosix, isCaseSensitive);
- if (previousSize !== watchedFiles.size) {
- watcher.watch({
- files: watchedFiles,
- });
+ const ignored = options?.ignored?.map((pattern) => {
+ if (/[*?[\]{}()]/.test(pattern)) {
+ const isMatch = picomatch(pattern, { dot: true });
+
+ return (filePath: string) => isMatch(toPosixPathNormalized(filePath));
+ }
+
+ return { path: toPosixPathNormalized(pattern), recursive: true };
+ });
+
+ const watcher = chokidar.watch(rootDir, {
+ ignoreInitial: true,
+ ignored,
+ followSymlinks: options?.followSymlinks,
+ usePolling: !!options?.polling,
+ interval: options?.interval,
+ });
+ const initTime = Date.now();
+
+ const handleEvent = (type: 'added' | 'modified' | 'removed', rawPath: string) => {
+ const posixPath = toPosixPathNormalized(rawPath);
+ const lookupKey = toLookupKey(posixPath, isCaseSensitive);
+ if (!isPathWatched(lookupKey, watchedFiles)) {
+ return;
+ }
+
+ if (type !== 'removed') {
+ const stat = fs.statSync(rawPath, { throwIfNoEntry: false });
+ // Ignore historical events from before watcher initialization, but allow a 1000 ms window
+ // to account for coarse filesystem timestamp resolution (e.g., ext4/overlayfs integer second
+ // mtime truncation on Linux) where files modified during startup may have truncated .000 ms mtimes.
+ if (stat && stat.mtimeMs < initTime - 1000) {
+ return;
}
+ }
+
+ queue.addChange(type, rawPath);
+ };
+
+ watcher.on('add', (path) => handleEvent('added', path));
+ watcher.on('change', (path) => handleEvent('modified', path));
+ watcher.on('unlink', (path) => handleEvent('removed', path));
+
+ const buildWatcher: BuildWatcher = {
+ [Symbol.asyncIterator]() {
+ return this;
},
- remove(paths) {
- const previousSize = watchedFiles.size;
- if (typeof paths === 'string') {
- watchedFiles.delete(paths);
- } else {
- for (const file of paths) {
- watchedFiles.delete(file);
+ next() {
+ return queue.next();
+ },
+
+ add(paths) {
+ const targets = typeof paths === 'string' ? [paths] : paths;
+ const newPaths: string[] = [];
+ for (const p of targets) {
+ const posixPath = toPosixPathNormalized(p);
+ const lookupKey = toLookupKey(posixPath, isCaseSensitive);
+ if (!watchedFiles.has(lookupKey)) {
+ watchedFiles.add(lookupKey);
+ if (!isPathInside(lookupKey, rootDirLookupKey) && lookupKey !== rootDirLookupKey) {
+ newPaths.push(posixPath);
+ }
}
}
+ if (newPaths.length > 0) {
+ watcher.add(newPaths);
+ }
+ },
- if (previousSize !== watchedFiles.size) {
- watcher.watch({
- files: watchedFiles,
- });
+ remove(paths) {
+ const targets = typeof paths === 'string' ? [paths] : paths;
+ const removePaths: string[] = [];
+ for (const p of targets) {
+ const posixPath = toPosixPathNormalized(p);
+ const lookupKey = toLookupKey(posixPath, isCaseSensitive);
+ if (watchedFiles.has(lookupKey)) {
+ watchedFiles.delete(lookupKey);
+ if (!isPathInside(lookupKey, rootDirLookupKey) && lookupKey !== rootDirLookupKey) {
+ removePaths.push(posixPath);
+ }
+ }
+ }
+ if (removePaths.length > 0) {
+ watcher.unwatch(removePaths);
}
},
async close() {
try {
- watcher.close();
+ await watcher.close();
} finally {
- let next;
- while ((next = nextQueue.shift()) !== undefined) {
- next();
- }
+ queue.close();
}
},
};
+
+ return buildWatcher;
}
diff --git a/packages/angular/build/src/tools/esbuild/watcher_spec.ts b/packages/angular/build/src/tools/esbuild/watcher_spec.ts
new file mode 100644
index 000000000000..2c82510a5ae5
--- /dev/null
+++ b/packages/angular/build/src/tools/esbuild/watcher_spec.ts
@@ -0,0 +1,486 @@
+/**
+ * @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 * as fs from 'node:fs';
+import * as os from 'node:os';
+import * as path from 'node:path';
+import { setTimeout } from 'node:timers/promises';
+import {
+ ChangedFiles,
+ createWatcher,
+ getDirectoryPath,
+ isPathInside,
+ toPosixPathNormalized,
+} from './watcher';
+
+describe('Watcher', () => {
+ describe('toPosixPathNormalized', () => {
+ it('should strip trailing slashes for standard directories', () => {
+ expect(toPosixPathNormalized('/src/app/')).toBe('/src/app');
+ expect(toPosixPathNormalized('C:/src/app/')).toBe('C:/src/app');
+ });
+
+ it('should preserve single root slash', () => {
+ expect(toPosixPathNormalized('/')).toBe('/');
+ });
+
+ it('should preserve trailing slash for Windows drive root', () => {
+ expect(toPosixPathNormalized('C:/')).toBe('C:/');
+ expect(toPosixPathNormalized('c:/')).toBe('c:/');
+ });
+ });
+
+ describe('getDirectoryPath', () => {
+ it('should return parent directory for POSIX paths', () => {
+ expect(getDirectoryPath('/src/app/main.ts')).toBe('/src/app');
+ expect(getDirectoryPath('/src/app')).toBe('/src');
+ expect(getDirectoryPath('/src')).toBe('/');
+ expect(getDirectoryPath('/')).toBe('/');
+ });
+
+ it('should correctly handle Windows drive roots', () => {
+ expect(getDirectoryPath('C:/src/app/main.ts')).toBe('C:/src/app');
+ expect(getDirectoryPath('C:/src')).toBe('C:/');
+ expect(getDirectoryPath('C:/')).toBe('C:/');
+ expect(getDirectoryPath('c:/')).toBe('c:/');
+ });
+
+ it('should return dot for relative paths without slash', () => {
+ expect(getDirectoryPath('main.ts')).toBe('.');
+ });
+ });
+
+ describe('isPathInside', () => {
+ it('should return true for a file inside a directory', () => {
+ expect(isPathInside('/src/app/main.ts', '/src/app')).toBeTrue();
+ });
+
+ it('should return false when file and dir are identical', () => {
+ expect(isPathInside('/src/app', '/src/app')).toBeFalse();
+ });
+
+ it('should return false for sibling directories with matching prefix', () => {
+ expect(isPathInside('/src/app-other/main.ts', '/src/app')).toBeFalse();
+ });
+
+ it('should handle Windows drive letters on the same drive', () => {
+ expect(isPathInside('c:/src/app/main.ts', 'c:/src/app')).toBeTrue();
+ });
+
+ it('should return false for Windows drive letters on different drives', () => {
+ expect(isPathInside('d:/src/app/main.ts', 'c:/src/app')).toBeFalse();
+ });
+
+ it('should handle root directory correctly', () => {
+ expect(isPathInside('/src/main.ts', '/')).toBeTrue();
+ });
+
+ it('should handle Windows drive root directory correctly', () => {
+ expect(isPathInside('c:/src/main.ts', 'c:/')).toBeTrue();
+ });
+ });
+
+ describe('ChangedFiles', () => {
+ it('should track added, modified, and removed files', () => {
+ const changes = new ChangedFiles();
+ changes.added.add('/src/app.component.ts');
+ changes.modified.add('/src/main.ts');
+ changes.removed.add('/src/old.ts');
+
+ expect(changes.all).toEqual(['/src/app.component.ts', '/src/main.ts', '/src/old.ts']);
+ });
+
+ it('should deduplicate files present in multiple sets in .all', () => {
+ const changes = new ChangedFiles();
+ changes.added.add('/src/main.ts');
+ changes.modified.add('/src/main.ts');
+
+ expect(changes.all).toEqual(['/src/main.ts']);
+ });
+
+ it('should format debug string correctly', () => {
+ const changes = new ChangedFiles();
+ changes.modified.add('/src/main.ts');
+
+ const debug = JSON.parse(changes.toDebugString());
+ expect(debug).toEqual({
+ added: [],
+ modified: ['/src/main.ts'],
+ removed: [],
+ });
+ });
+ });
+
+ describe('createWatcher', () => {
+ let tempDir: string;
+
+ beforeEach(() => {
+ tempDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'watcher-spec-')));
+ });
+
+ afterEach(() => {
+ if (fs.existsSync(tempDir)) {
+ fs.rmSync(tempDir, { recursive: true, force: true });
+ }
+ });
+
+ it('should instantiate and close watcher without error', async () => {
+ const watcher = await createWatcher({ cwd: tempDir });
+ expect(watcher).toBeDefined();
+
+ watcher.add(path.join(tempDir, 'main.ts'));
+ watcher.remove(path.join(tempDir, 'main.ts'));
+
+ await watcher.close();
+ });
+
+ it('should support array of paths in add and remove', async () => {
+ const watcher = await createWatcher({ cwd: tempDir });
+ const file1 = path.join(tempDir, 'a.ts');
+ const file2 = path.join(tempDir, 'b.ts');
+
+ watcher.add([file1, file2]);
+ watcher.remove([file1, file2]);
+
+ await watcher.close();
+ });
+
+ it('should support polling option', async () => {
+ const watcher = await createWatcher({ polling: true, interval: 100, cwd: tempDir });
+ expect(watcher).toBeDefined();
+
+ watcher.add(path.join(tempDir, 'main.ts'));
+ await watcher.close();
+ });
+
+ it('should emit changes when a watched file is modified (chokidar polling)', async () => {
+ const testFile = path.join(tempDir, 'test.txt');
+ fs.writeFileSync(testFile, 'initial');
+
+ const watcher = await createWatcher({ polling: true, interval: 50, cwd: tempDir });
+ watcher.add(testFile);
+
+ // Wait a short moment for watcher setup and mtime tick
+ await setTimeout(100);
+
+ const iterator = watcher[Symbol.asyncIterator]();
+ const nextPromise = iterator.next();
+
+ // Trigger change
+ fs.writeFileSync(testFile, 'updated');
+
+ const result = await nextPromise;
+ expect(result.done).toBeFalsy();
+ expect(result.value?.all.length).toBeGreaterThan(0);
+
+ await watcher.close();
+ }, 10000);
+
+ it('should preserve original path character casing in emitted changes', async () => {
+ const casedFile = path.join(tempDir, 'App.Component.ts');
+ fs.writeFileSync(casedFile, 'initial');
+
+ const watcher = await createWatcher({ polling: true, interval: 50, cwd: tempDir });
+ watcher.add(casedFile);
+
+ await setTimeout(100);
+
+ const iterator = watcher[Symbol.asyncIterator]();
+ const nextPromise = iterator.next();
+
+ fs.writeFileSync(casedFile, 'updated');
+
+ const result = await nextPromise;
+ expect(result.done).toBeFalsy();
+ const emittedFiles = result.value?.all ?? [];
+ expect(emittedFiles.some((f: string) => f.includes('App.Component.ts'))).toBeTrue();
+
+ await watcher.close();
+ }, 10000);
+
+ it('should emit changes when watching a directory containing modified files', async () => {
+ const subDir = path.join(tempDir, 'sub');
+ fs.mkdirSync(subDir);
+ const testFile = path.join(subDir, 'nested.txt');
+ fs.writeFileSync(testFile, 'initial');
+
+ const watcher = await createWatcher({ polling: true, interval: 50, cwd: tempDir });
+ watcher.add(subDir);
+
+ await setTimeout(100);
+
+ const iterator = watcher[Symbol.asyncIterator]();
+ const nextPromise = iterator.next();
+
+ fs.writeFileSync(testFile, 'updated');
+
+ const result = await nextPromise;
+ expect(result.done).toBeFalsy();
+ expect(result.value?.all.length).toBeGreaterThan(0);
+
+ await watcher.close();
+ }, 10000);
+
+ it('should support watching paths outside cwd', async () => {
+ const externalDir = fs.mkdtempSync(path.join(os.tmpdir(), 'external-watcher-spec-'));
+ const externalFile = path.join(externalDir, 'external.txt');
+ fs.writeFileSync(externalFile, 'initial');
+
+ try {
+ const watcher = await createWatcher({ polling: true, interval: 50, cwd: tempDir });
+ watcher.add(externalFile);
+
+ await setTimeout(100);
+
+ const iterator = watcher[Symbol.asyncIterator]();
+ const nextPromise = iterator.next();
+
+ fs.writeFileSync(externalFile, 'updated');
+
+ const result = await nextPromise;
+ expect(result.done).toBeFalsy();
+ expect(result.value?.all.some((f: string) => f.includes('external.txt'))).toBeTrue();
+
+ await watcher.close();
+ } finally {
+ fs.rmSync(externalDir, { recursive: true, force: true });
+ }
+ }, 10000);
+
+ it('should handle adding multiple external files in the same directory concurrently', async () => {
+ const externalDir = fs.mkdtempSync(path.join(os.tmpdir(), 'external-watcher-spec-'));
+ const file1 = path.join(externalDir, 'file1.txt');
+ const file2 = path.join(externalDir, 'file2.txt');
+ fs.writeFileSync(file1, 'initial1');
+ fs.writeFileSync(file2, 'initial2');
+
+ try {
+ const watcher = await createWatcher({ polling: true, interval: 50, cwd: tempDir });
+ watcher.add([file1, file2]);
+
+ await setTimeout(100);
+
+ const iterator = watcher[Symbol.asyncIterator]();
+ let nextPromise = iterator.next();
+ fs.writeFileSync(file1, 'updated1');
+ let result = await nextPromise;
+ expect(result.value?.all.some((f: string) => f.includes('file1.txt'))).toBeTrue();
+
+ nextPromise = iterator.next();
+ fs.writeFileSync(file2, 'updated2');
+ result = await nextPromise;
+ expect(result.value?.all.some((f: string) => f.includes('file2.txt'))).toBeTrue();
+
+ await watcher.close();
+ } finally {
+ fs.rmSync(externalDir, { recursive: true, force: true });
+ }
+ }, 10000);
+
+ it('should clean up external subscriptions when all external files in a directory are removed', async () => {
+ const externalDir = fs.mkdtempSync(path.join(os.tmpdir(), 'external-watcher-spec-'));
+ const file1 = path.join(externalDir, 'file1.txt');
+ const file2 = path.join(externalDir, 'file2.txt');
+ fs.writeFileSync(file1, 'initial1');
+ fs.writeFileSync(file2, 'initial2');
+
+ try {
+ const watcher = await createWatcher({ polling: true, interval: 50, cwd: tempDir });
+ watcher.add([file1, file2]);
+
+ await setTimeout(100);
+
+ // Remove files from watcher
+ watcher.remove(file1);
+ watcher.remove(file2);
+
+ await watcher.close();
+ } finally {
+ fs.rmSync(externalDir, { recursive: true, force: true });
+ }
+ });
+
+ it('should handle nested external directories without creating duplicate subscriptions', async () => {
+ const externalDir = fs.mkdtempSync(path.join(os.tmpdir(), 'external-watcher-spec-'));
+ const subDir = path.join(externalDir, 'sub');
+ fs.mkdirSync(subDir);
+ const parentFile = path.join(externalDir, 'parent.txt');
+ const childFile = path.join(subDir, 'child.txt');
+ fs.writeFileSync(parentFile, 'initial-parent');
+ fs.writeFileSync(childFile, 'initial-child');
+
+ try {
+ const watcher = await createWatcher({ polling: true, interval: 50, cwd: tempDir });
+ watcher.add(parentFile);
+ watcher.add(childFile);
+
+ await setTimeout(100);
+
+ const iterator = watcher[Symbol.asyncIterator]();
+ const nextPromise = iterator.next();
+
+ fs.writeFileSync(childFile, 'updated-child');
+
+ const result = await nextPromise;
+ expect(result.done).toBeFalsy();
+ expect(result.value?.all.some((f: string) => f.includes('child.txt'))).toBeTrue();
+
+ await watcher.close();
+ } finally {
+ fs.rmSync(externalDir, { recursive: true, force: true });
+ }
+ }, 10000);
+
+ it('should subscribe to subsumed external child directory when parent external subscription is removed', async () => {
+ const externalDir = fs.mkdtempSync(path.join(os.tmpdir(), 'external-watcher-spec-'));
+ const subDir = path.join(externalDir, 'sub');
+ fs.mkdirSync(subDir);
+ const parentFile = path.join(externalDir, 'parent.txt');
+ const childFile = path.join(subDir, 'child.txt');
+ fs.writeFileSync(parentFile, 'initial-parent');
+ fs.writeFileSync(childFile, 'initial-child');
+
+ try {
+ const watcher = await createWatcher({ polling: true, interval: 50, cwd: tempDir });
+ watcher.add(parentFile);
+ watcher.add(childFile);
+
+ await setTimeout(100);
+
+ watcher.remove(parentFile);
+
+ await setTimeout(100);
+
+ const iterator = watcher[Symbol.asyncIterator]();
+ const nextPromise = iterator.next();
+
+ fs.writeFileSync(childFile, 'updated-child');
+
+ const result = await nextPromise;
+ expect(result.done).toBeFalsy();
+ expect(result.value?.all.some((f: string) => f.includes('child.txt'))).toBeTrue();
+
+ await watcher.close();
+ } finally {
+ fs.rmSync(externalDir, { recursive: true, force: true });
+ }
+ }, 10000);
+
+ it('should signal completion on close', async () => {
+ const watcher = await createWatcher({ polling: true, interval: 50, cwd: tempDir });
+ const iterator = watcher[Symbol.asyncIterator]();
+
+ const nextPromise = iterator.next();
+ await watcher.close();
+
+ const result = await nextPromise;
+ expect(result.done).toBeTrue();
+ });
+
+ it('should return done immediately if next() is called after close()', async () => {
+ const watcher = await createWatcher({ polling: true, interval: 50, cwd: tempDir });
+ await watcher.close();
+
+ const result = await watcher.next();
+ expect(result.done).toBeTrue();
+ });
+
+ it('should ignore stale modifications before initTime and emit changes after initTime (@parcel/watcher)', async () => {
+ const testFile = path.join(tempDir, 'test.txt');
+ fs.writeFileSync(testFile, 'initial');
+
+ // Small delay to ensure initial mtimeMs is strictly earlier than initTime - 1000
+ await setTimeout(1100);
+
+ // Create native @parcel/watcher (polling: false / default)
+ const watcher = await createWatcher({ cwd: tempDir });
+ watcher.add(testFile);
+
+ // Wait a short moment for native watcher setup and kernel event stream initialization
+ await setTimeout(150);
+
+ const iterator = watcher[Symbol.asyncIterator]();
+ const nextPromise = iterator.next();
+
+ // Trigger a change after watcher initialization
+ fs.writeFileSync(testFile, 'updated');
+
+ const result = await nextPromise;
+ expect(result.done).toBeFalsy();
+ expect(result.value?.all.some((f: string) => f.includes('test.txt'))).toBeTrue();
+
+ await watcher.close();
+ }, 10000);
+
+ it('should emit changes when a file is deleted and recreated with stabilization delay (@parcel/watcher)', async () => {
+ const testFile = path.join(tempDir, 'recreate.txt');
+ fs.writeFileSync(testFile, 'initial');
+
+ await setTimeout(50);
+
+ const watcher = await createWatcher({ cwd: tempDir });
+ watcher.add(testFile);
+
+ await setTimeout(150);
+
+ const iterator = watcher[Symbol.asyncIterator]();
+
+ // Delete the file
+ fs.rmSync(testFile);
+ let result = await iterator.next();
+ expect(result.done).toBeFalsy();
+ expect(result.value?.removed.size).toBeGreaterThan(0);
+
+ // Brief stabilization delay before recreating to prevent macOS fsevents kernel driver
+ // from coalescing unlink and create into a single directory event
+ await setTimeout(150);
+
+ // Recreate the file
+ fs.writeFileSync(testFile, 'recreated');
+ result = await iterator.next();
+ expect(result.done).toBeFalsy();
+ expect(result.value?.added.size).toBeGreaterThan(0);
+
+ await watcher.close();
+ }, 10000);
+
+ it('should ignore changes matching glob patterns in polling mode (chokidar)', async () => {
+ const ignoredDir = path.join(tempDir, 'dist');
+ fs.mkdirSync(ignoredDir);
+ const ignoredFile = path.join(ignoredDir, 'bundle.js');
+ const watchedFile = path.join(tempDir, 'src.ts');
+ fs.writeFileSync(ignoredFile, 'initial-dist');
+ fs.writeFileSync(watchedFile, 'initial-src');
+
+ const watcher = await createWatcher({
+ polling: true,
+ interval: 50,
+ cwd: tempDir,
+ ignored: [`${toPosixPathNormalized(ignoredDir)}/**`],
+ });
+
+ watcher.add(tempDir);
+ await setTimeout(100);
+
+ const iterator = watcher[Symbol.asyncIterator]();
+ const nextPromise = iterator.next();
+
+ // Trigger changes in ignored file and watched file
+ fs.writeFileSync(ignoredFile, 'updated-dist');
+ fs.writeFileSync(watchedFile, 'updated-src');
+
+ const result = await nextPromise;
+ expect(result.done).toBeFalsy();
+ const emitted = result.value?.all ?? [];
+ expect(emitted.some((f: string) => f.includes('src.ts'))).toBeTrue();
+ expect(emitted.some((f: string) => f.includes('bundle.js'))).toBeFalse();
+
+ await watcher.close();
+ }, 10000);
+ });
+});
diff --git a/packages/angular/build/src/tools/oxc/adjust-static-class-members_oxc_spec.ts b/packages/angular/build/src/tools/oxc/adjust-static-class-members_oxc_spec.ts
new file mode 100644
index 000000000000..7fe8c68030fc
--- /dev/null
+++ b/packages/angular/build/src/tools/oxc/adjust-static-class-members_oxc_spec.ts
@@ -0,0 +1,1103 @@
+/**
+ * @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 { transform } from './oxc-transform';
+
+const NO_CHANGE = Symbol('NO_CHANGE');
+
+function cleanCode(code: string): string {
+ return code.replace(/\s+/g, '').replace(/,([}\])])/g, '$1');
+}
+
+function testCase({
+ input,
+ expected,
+ options,
+}: {
+ input: string;
+ expected: string | typeof NO_CHANGE;
+ options?: { wrapDecorators?: boolean };
+}): jasmine.ImplementationCallback {
+ return async () => {
+ const result = transform('test.js', input, {
+ sourcemap: false,
+ sideEffects: options?.wrapDecorators ? false : true,
+ pureAnnotate: false,
+ });
+ if (!result?.code) {
+ fail('Expected oxc-transform to return a transform result.');
+ } else {
+ const actualClean = cleanCode(result.code);
+ const expectedClean = cleanCode(expected === NO_CHANGE ? input : expected);
+ expect(actualClean).toEqual(expectedClean);
+ }
+ };
+}
+
+describe('adjust-static-class-members oxc-transform implementation', () => {
+ it(
+ 'elides empty ctorParameters function expression static field',
+ testCase({
+ input: `
+ export class SomeClass {}
+ SomeClass.ctorParameters = function () { return []; };
+ `,
+ expected: 'export class SomeClass {}',
+ }),
+ );
+
+ it(
+ 'elides non-empty ctorParameters function expression static field',
+ testCase({
+ input: `
+ export class SomeClass {}
+ SomeClass.ctorParameters = function () { return [{type: Injector}]; };
+ `,
+ expected: 'export class SomeClass {}',
+ }),
+ );
+
+ it(
+ 'elides empty ctorParameters arrow expression static field',
+ testCase({
+ input: `
+ export class SomeClass {}
+ SomeClass.ctorParameters = () => [];
+ `,
+ expected: 'export class SomeClass {}',
+ }),
+ );
+
+ it(
+ 'elides non-empty ctorParameters arrow expression static field',
+ testCase({
+ input: `
+ export class SomeClass {}
+ SomeClass.ctorParameters = () => [{type: Injector}];
+ `,
+ expected: 'export class SomeClass {}',
+ }),
+ );
+
+ it(
+ 'keeps ctorParameters static field without arrow/function expression',
+ testCase({
+ input: `
+ export class SomeClass {}
+ SomeClass.ctorParameters = 42;
+ `,
+ expected: `
+ export let SomeClass = /*#__PURE__*/ (() => {
+ class SomeClass {}
+ SomeClass.ctorParameters = 42;
+ return SomeClass;
+ })();
+ `,
+ }),
+ );
+
+ it(
+ 'elides empty decorators static field with array literal',
+ testCase({
+ input: `
+ export class SomeClass {}
+ SomeClass.decorators = [];
+ `,
+ expected: 'export class SomeClass {}',
+ }),
+ );
+
+ it(
+ 'elides non-empty decorators static field with array literal',
+ testCase({
+ input: `
+ export class SomeClass {}
+ SomeClass.decorators = [{ type: Injectable }];
+ `,
+ expected: 'export class SomeClass {}',
+ }),
+ );
+
+ it(
+ 'keeps decorators static field without array literal',
+ testCase({
+ input: `
+ export class SomeClass {}
+ SomeClass.decorators = 42;
+ `,
+ expected: `
+ export let SomeClass = /*#__PURE__*/ (() => {
+ class SomeClass {}
+ SomeClass.decorators = 42;
+ return SomeClass;
+ })();
+ `,
+ }),
+ );
+
+ it(
+ 'elides empty propDecorators static field with object literal',
+ testCase({
+ input: `
+ export class SomeClass {}
+ SomeClass.propDecorators = {};
+ `,
+ expected: 'export class SomeClass {}',
+ }),
+ );
+
+ it(
+ 'elides non-empty propDecorators static field with object literal',
+ testCase({
+ input: `
+ export class SomeClass {}
+ SomeClass.propDecorators = { 'ngIf': [{ type: Input }] };
+ `,
+ expected: 'export class SomeClass {}',
+ }),
+ );
+
+ it(
+ 'keeps propDecorators static field without object literal',
+ testCase({
+ input: `
+ export class SomeClass {}
+ SomeClass.propDecorators = 42;
+ `,
+ expected: `
+ export let SomeClass = /*#__PURE__*/ (() => {
+ class SomeClass {}
+ SomeClass.propDecorators = 42;
+ return SomeClass;
+ })();
+ `,
+ }),
+ );
+
+ it(
+ 'does not wrap default exported class with no connected siblings',
+ testCase({
+ // NOTE: This could technically have no changes but the default export splitting detection
+ // does not perform class property analysis currently.
+ input: `
+ export default class CustomComponentEffects {
+ constructor(_actions) {
+ this._actions = _actions;
+ this.doThis = this._actions;
+ }
+ }
+ `,
+ expected: `
+ class CustomComponentEffects {
+ constructor(_actions) {
+ this._actions = _actions;
+ this.doThis = this._actions;
+ }
+ }
+ export { CustomComponentEffects as default };
+ `,
+ }),
+ );
+
+ it(
+ 'does wrap not default exported class with only side effect fields',
+ testCase({
+ input: `
+ export default class CustomComponentEffects {
+ constructor(_actions) {
+ this._actions = _actions;
+ this.doThis = this._actions;
+ }
+ }
+ CustomComponentEffects.someFieldWithSideEffects = console.log('foo');
+ `,
+ expected: NO_CHANGE,
+ }),
+ );
+
+ it(
+ 'does not wrap class with only side effect fields',
+ testCase({
+ input: `
+ class CustomComponentEffects {
+ constructor(_actions) {
+ this._actions = _actions;
+ this.doThis = this._actions;
+ }
+ }
+ CustomComponentEffects.someFieldWithSideEffects = console.log('foo');
+ `,
+ expected: NO_CHANGE,
+ }),
+ );
+
+ it(
+ 'does not wrap class with only side effect native fields',
+ testCase({
+ input: `
+ class CustomComponentEffects {
+ static someFieldWithSideEffects = console.log('foo');
+ constructor(_actions) {
+ this._actions = _actions;
+ this.doThis = this._actions;
+ }
+ }
+ `,
+ expected: NO_CHANGE,
+ }),
+ );
+
+ it(
+ 'does not wrap class with only instance native fields',
+ testCase({
+ input: `
+ class CustomComponentEffects {
+ someFieldWithSideEffects = console.log('foo');
+ constructor(_actions) {
+ this._actions = _actions;
+ this.doThis = this._actions;
+ }
+ }
+ `,
+ expected: NO_CHANGE,
+ }),
+ );
+
+ it(
+ 'wraps class with pure annotated side effect fields (#__PURE__)',
+ testCase({
+ input: `
+ class CustomComponentEffects {
+ constructor(_actions) {
+ this._actions = _actions;
+ this.doThis = this._actions;
+ }
+ }
+ CustomComponentEffects.someFieldWithSideEffects = /*#__PURE__*/ console.log('foo');
+ `,
+ expected: `
+ let CustomComponentEffects = /*#__PURE__*/ (() => {
+ class CustomComponentEffects {
+ constructor(_actions) {
+ this._actions = _actions;
+ this.doThis = this._actions;
+ }
+ }
+ CustomComponentEffects.someFieldWithSideEffects = /*#__PURE__*/ console.log('foo');
+ return CustomComponentEffects;
+ })();
+ `,
+ }),
+ );
+
+ it(
+ 'wraps class with pure annotated side effect native fields (#__PURE__)',
+ testCase({
+ input: `
+ class CustomComponentEffects {
+ static someFieldWithSideEffects = /*#__PURE__*/ console.log('foo');
+ constructor(_actions) {
+ this._actions = _actions;
+ this.doThis = this._actions;
+ }
+ }
+ `,
+ expected: `
+ let CustomComponentEffects = /*#__PURE__*/ (() => {
+ class CustomComponentEffects {
+ static someFieldWithSideEffects = /*#__PURE__*/ console.log('foo');
+ constructor(_actions) {
+ this._actions = _actions;
+ this.doThis = this._actions;
+ }
+ }
+ return CustomComponentEffects;
+ })();
+ `,
+ }),
+ );
+
+ it(
+ 'wraps class with pure annotated side effect fields (@__PURE__)',
+ testCase({
+ input: `
+ class CustomComponentEffects {
+ constructor(_actions) {
+ this._actions = _actions;
+ this.doThis = this._actions;
+ }
+ }
+ CustomComponentEffects.someFieldWithSideEffects = /*@__PURE__*/ console.log('foo');
+ `,
+ expected: `
+ let CustomComponentEffects = /*#__PURE__*/ (() => {
+ class CustomComponentEffects {
+ constructor(_actions) {
+ this._actions = _actions;
+ this.doThis = this._actions;
+ }
+ }
+ CustomComponentEffects.someFieldWithSideEffects = /*@__PURE__*/ console.log('foo');
+ return CustomComponentEffects;
+ })();
+ `,
+ }),
+ );
+
+ it(
+ 'wraps class with pure annotated side effect fields (@pureOrBreakMyCode)',
+ testCase({
+ input: `
+ class CustomComponentEffects {
+ constructor(_actions) {
+ this._actions = _actions;
+ this.doThis = this._actions;
+ }
+ }
+ CustomComponentEffects.someFieldWithSideEffects = /**@pureOrBreakMyCode*/ console.log('foo');
+ `,
+ expected: `
+ let CustomComponentEffects = /*#__PURE__*/ (() => {
+ class CustomComponentEffects {
+ constructor(_actions) {
+ this._actions = _actions;
+ this.doThis = this._actions;
+ }
+ }
+ CustomComponentEffects.someFieldWithSideEffects =
+ /**@pureOrBreakMyCode*/ console.log('foo');
+ return CustomComponentEffects;
+ })();
+ `,
+ }),
+ );
+
+ it(
+ 'wraps class with closure pure annotated side effect fields',
+ testCase({
+ input: `
+ class CustomComponentEffects {
+ constructor(_actions) {
+ this._actions = _actions;
+ this.doThis = this._actions;
+ }
+ }
+ CustomComponentEffects.someFieldWithSideEffects = /* @pureOrBreakMyCode */ console.log('foo');
+ `,
+ expected: `
+ let CustomComponentEffects = /*#__PURE__*/ (() => {
+ class CustomComponentEffects {
+ constructor(_actions) {
+ this._actions = _actions;
+ this.doThis = this._actions;
+ }
+ }
+ CustomComponentEffects.someFieldWithSideEffects =
+ /* @pureOrBreakMyCode */ console.log('foo');
+ return CustomComponentEffects;
+ })();
+ `,
+ }),
+ );
+
+ it(
+ 'wraps exported class with a pure static field',
+ testCase({
+ input: `
+ export class CustomComponentEffects {
+ constructor(_actions) {
+ this._actions = _actions;
+ this.doThis = this._actions;
+ }
+ }
+ CustomComponentEffects.someField = 42;
+ `,
+ expected: `
+ export let CustomComponentEffects = /*#__PURE__*/ (() => {
+ class CustomComponentEffects {
+ constructor(_actions) {
+ this._actions = _actions;
+ this.doThis = this._actions;
+ }
+ }
+ CustomComponentEffects.someField = 42;
+ return CustomComponentEffects;
+ })();
+ `,
+ }),
+ );
+
+ it(
+ 'wraps exported class with a pure native static field',
+ testCase({
+ input: `
+ export class CustomComponentEffects {
+ static someField = 42;
+ constructor(_actions) {
+ this._actions = _actions;
+ this.doThis = this._actions;
+ }
+ }
+ `,
+ expected: `
+ export let CustomComponentEffects = /*#__PURE__*/ (() => {
+ class CustomComponentEffects {
+ static someField = 42;
+ constructor(_actions) {
+ this._actions = _actions;
+ this.doThis = this._actions;
+ }
+ }
+ return CustomComponentEffects;
+ })();
+ `,
+ }),
+ );
+
+ it(
+ 'wraps class with a basic literal static field',
+ testCase({
+ input: `
+ class CustomComponentEffects {
+ constructor(_actions) {
+ this._actions = _actions;
+ this.doThis = this._actions;
+ }
+ }
+ CustomComponentEffects.someField = 42;
+ `,
+ expected: `
+ let CustomComponentEffects = /*#__PURE__*/ (() => {
+ class CustomComponentEffects {
+ constructor(_actions) {
+ this._actions = _actions;
+ this.doThis = this._actions;
+ }
+ }
+ CustomComponentEffects.someField = 42;
+ return CustomComponentEffects;
+ })();
+ `,
+ }),
+ );
+
+ it(
+ 'wraps class with a pure static field',
+ testCase({
+ input: `
+ const SWITCH_TEMPLATE_REF_FACTORY__POST_R3__ = injectTemplateRef;
+ const SWITCH_TEMPLATE_REF_FACTORY = SWITCH_TEMPLATE_REF_FACTORY__POST_R3__;
+ class TemplateRef {}
+ TemplateRef.__NG_ELEMENT_ID__ = SWITCH_TEMPLATE_REF_FACTORY;
+ `,
+ expected: `
+ const SWITCH_TEMPLATE_REF_FACTORY__POST_R3__ = injectTemplateRef;
+ const SWITCH_TEMPLATE_REF_FACTORY = SWITCH_TEMPLATE_REF_FACTORY__POST_R3__;
+ let TemplateRef = /*#__PURE__*/ (() => {
+ class TemplateRef {}
+ TemplateRef.__NG_ELEMENT_ID__ = SWITCH_TEMPLATE_REF_FACTORY;
+ return TemplateRef;
+ })();
+ `,
+ }),
+ );
+
+ it(
+ 'wraps class with multiple pure static field',
+ testCase({
+ input: `
+ const SWITCH_TEMPLATE_REF_FACTORY__POST_R3__ = injectTemplateRef;
+ const SWITCH_TEMPLATE_REF_FACTORY = SWITCH_TEMPLATE_REF_FACTORY__POST_R3__;
+ class TemplateRef {}
+ TemplateRef.__NG_ELEMENT_ID__ = SWITCH_TEMPLATE_REF_FACTORY;
+ TemplateRef.someField = 42;
+ `,
+ expected: `
+ const SWITCH_TEMPLATE_REF_FACTORY__POST_R3__ = injectTemplateRef;
+ const SWITCH_TEMPLATE_REF_FACTORY = SWITCH_TEMPLATE_REF_FACTORY__POST_R3__;
+ let TemplateRef = /*#__PURE__*/ (() => {
+ class TemplateRef {}
+ TemplateRef.__NG_ELEMENT_ID__ = SWITCH_TEMPLATE_REF_FACTORY;
+ TemplateRef.someField = 42;
+ return TemplateRef;
+ })();
+ `,
+ }),
+ );
+
+ it(
+ 'does not wrap class with only some pure static fields',
+ testCase({
+ input: `
+ class CustomComponentEffects {
+ constructor(_actions) {
+ this._actions = _actions;
+ this.doThis = this._actions;
+ }
+ }
+ CustomComponentEffects.someField = 42;
+ CustomComponentEffects.someFieldWithSideEffects = console.log('foo');
+ `,
+ expected: NO_CHANGE,
+ }),
+ );
+
+ it(
+ 'does not wrap class with only pure native static fields and some side effect static fields',
+ testCase({
+ input: `
+ class CustomComponentEffects {
+ static someField = 42;
+ constructor(_actions) {
+ this._actions = _actions;
+ this.doThis = this._actions;
+ }
+ }
+ CustomComponentEffects.someFieldWithSideEffects = console.log('foo');
+ `,
+ expected: NO_CHANGE,
+ }),
+ );
+
+ it(
+ 'does not wrap class with only some pure native static fields',
+ testCase({
+ input: `
+ class CustomComponentEffects {
+ static someField = 42;
+ static someFieldWithSideEffects = console.log('foo');
+ constructor(_actions) {
+ this._actions = _actions;
+ this.doThis = this._actions;
+ }
+ }
+ `,
+ expected: NO_CHANGE,
+ }),
+ );
+
+ it(
+ 'does not wrap class with class decorators when wrapDecorators is false',
+ testCase({
+ input: `
+ let SomeClass = class SomeClass {
+ };
+ SomeClass = __decorate([
+ Dec()
+ ], SomeClass);
+ `,
+ expected: NO_CHANGE,
+ options: { wrapDecorators: false },
+ }),
+ );
+
+ it(
+ 'wraps class with Angular ɵfac static field (esbuild)',
+ testCase({
+ input: `
+ var Comp2Component = class _Comp2Component {
+ static {
+ this.ɵfac = function Comp2Component_Factory(t) {
+ return new (t || _Comp2Component)();
+ };
+ }
+ };
+ `,
+ expected: `
+ var Comp2Component = /*#__PURE__*/ (() => {
+ let Comp2Component = class _Comp2Component {
+ static {
+ this.ɵfac = function Comp2Component_Factory(t) {
+ return new (t || _Comp2Component)();
+ };
+ }
+ };
+ return Comp2Component;
+ })();
+ `,
+ }),
+ );
+
+ it(
+ 'wraps class with class decorators when wrapDecorators is true (esbuild output)',
+ testCase({
+ input: `
+ var ExampleClass = class {
+ method() {
+ }
+ };
+ __decorate([
+ SomeDecorator()
+ ], ExampleClass.prototype, "method", null);
+ `,
+ expected: `
+ var ExampleClass = /*#__PURE__*/ (() => {
+ let ExampleClass = class {
+ method() {}
+ };
+ __decorate([SomeDecorator()], ExampleClass.prototype, "method", null);
+ return ExampleClass;
+ })();
+ `,
+ options: { wrapDecorators: true },
+ }),
+ );
+
+ it(
+ 'wraps class with class decorators when wrapDecorators is true',
+ testCase({
+ input: `
+ let SomeClass = class SomeClass {
+ };
+ SomeClass = __decorate([
+ SomeDecorator()
+ ], SomeClass);
+ `,
+ expected: `
+ let SomeClass = /*#__PURE__*/ (() => {
+ let SomeClass = class SomeClass {
+ };
+ SomeClass = __decorate([
+ SomeDecorator()
+ ], SomeClass);
+ return SomeClass;
+ })();
+ `,
+ options: { wrapDecorators: true },
+ }),
+ );
+
+ it(
+ 'does not wrap class with constructor decorators when wrapDecorators is false',
+ testCase({
+ input: `
+ let SomeClass = class SomeClass {
+ constructor(foo) { }
+ };
+ SomeClass = __decorate([
+ __param(0, SomeDecorator)
+ ], SomeClass);
+ `,
+ expected: NO_CHANGE,
+ options: { wrapDecorators: false },
+ }),
+ );
+
+ it(
+ 'wraps class with constructor decorators when wrapDecorators is true',
+ testCase({
+ input: `
+ let SomeClass = class SomeClass {
+ constructor(foo) { }
+ };
+ SomeClass = __decorate([
+ __param(0, SomeDecorator)
+ ], SomeClass);
+ `,
+ expected: `
+ let SomeClass = /*#__PURE__*/ (() => {
+ let SomeClass = class SomeClass {
+ constructor(foo) { }
+ };
+ SomeClass = __decorate([
+ __param(0, SomeDecorator)
+ ], SomeClass);
+ return SomeClass;
+ })();
+ `,
+ options: { wrapDecorators: true },
+ }),
+ );
+
+ it(
+ 'does not wrap class with field decorators when wrapDecorators is false',
+ testCase({
+ input: `
+ class SomeClass {
+ constructor() {
+ this.foo = 42;
+ }
+ }
+ __decorate([
+ SomeDecorator
+ ], SomeClass.prototype, "foo", void 0);
+ `,
+ expected: NO_CHANGE,
+ options: { wrapDecorators: false },
+ }),
+ );
+
+ it(
+ 'wraps class with field decorators when wrapDecorators is true',
+ testCase({
+ input: `
+ class SomeClass {
+ constructor() {
+ this.foo = 42;
+ }
+ }
+ __decorate([
+ SomeDecorator
+ ], SomeClass.prototype, "foo", void 0);
+ `,
+ expected: `
+ let SomeClass = /*#__PURE__*/ (() => {
+ class SomeClass {
+ constructor() {
+ this.foo = 42;
+ }
+ }
+ __decorate([
+ SomeDecorator
+ ], SomeClass.prototype, "foo", void 0);
+ return SomeClass;
+ })();
+ `,
+ options: { wrapDecorators: true },
+ }),
+ );
+
+ it(
+ 'wraps class with Angular ɵfac static field',
+ testCase({
+ input: `
+ class CommonModule {
+ }
+ CommonModule.ɵfac = function CommonModule_Factory(t) { return new (t || CommonModule)(); };
+ `,
+ expected: `
+ let CommonModule = /*#__PURE__*/ (() => {
+ class CommonModule {
+ }
+ CommonModule.ɵfac = function CommonModule_Factory(t) { return new (t || CommonModule)(); };
+ return CommonModule;
+ })();
+ `,
+ }),
+ );
+
+ it(
+ 'wraps class with Angular ɵfac static block (ES2022 + useDefineForClassFields: false)',
+ testCase({
+ input: `
+ class CommonModule {
+ static { this.ɵfac = function CommonModule_Factory(t) { return new (t || CommonModule)(); }; }
+ static { this.ɵmod = ɵngcc0.ɵɵdefineNgModule({ type: CommonModule }); }
+ }
+ `,
+ expected: `
+ let CommonModule = /*#__PURE__*/ (() => {
+ class CommonModule {
+ static {
+ this.ɵfac = function CommonModule_Factory(t) {
+ return new (t || CommonModule)();
+ };
+ }
+ static {
+ this.ɵmod = ɵngcc0.ɵɵdefineNgModule({
+ type: CommonModule,
+ });
+ }
+ }
+ return CommonModule;
+ })();
+ `,
+ }),
+ );
+
+ it(
+ 'does not wrap class with side effect full static block (ES2022 + useDefineForClassFields: false)',
+ testCase({
+ input: `
+ class CommonModule {
+ static { globalThis.bar = 1 }
+ }
+ `,
+ expected: NO_CHANGE,
+ }),
+ );
+
+ it(
+ 'wraps class with Angular ɵmod static field',
+ testCase({
+ input: `
+ class CommonModule {
+ }
+ CommonModule.ɵmod = /*@__PURE__*/ ɵngcc0.ɵɵdefineNgModule({ type: CommonModule });
+ `,
+ expected: `
+ let CommonModule = /*#__PURE__*/ (() => {
+ class CommonModule {
+ }
+ CommonModule.ɵmod = /*@__PURE__*/ ɵngcc0.ɵɵdefineNgModule({ type: CommonModule });
+ return CommonModule;
+ })();
+ `,
+ }),
+ );
+
+ it(
+ 'wraps class with Angular ɵinj static field',
+ testCase({
+ input: `
+ class CommonModule {
+ }
+ CommonModule.ɵinj = /*@__PURE__*/ ɵngcc0.ɵɵdefineInjector({ providers: [
+ { provide: NgLocalization, useClass: NgLocaleLocalization },
+ ] });
+ `,
+ expected: `
+ let CommonModule = /*#__PURE__*/ (() => {
+ class CommonModule {
+ }
+ CommonModule.ɵinj = /*@__PURE__*/ ɵngcc0.ɵɵdefineInjector({ providers: [
+ {
+ provide: NgLocalization,
+ useClass: NgLocaleLocalization
+ },
+ ] });
+ return CommonModule;
+ })();
+ `,
+ }),
+ );
+
+ it(
+ 'wraps class with multiple Angular static fields',
+ testCase({
+ input: `
+ class CommonModule {
+ }
+ CommonModule.ɵfac = function CommonModule_Factory(t) { return new (t || CommonModule)(); };
+ CommonModule.ɵmod = /*@__PURE__*/ ɵngcc0.ɵɵdefineNgModule({ type: CommonModule });
+ CommonModule.ɵinj = /*@__PURE__*/ ɵngcc0.ɵɵdefineInjector({ providers: [
+ { provide: NgLocalization, useClass: NgLocaleLocalization },
+ ] });
+ `,
+ expected: `
+ let CommonModule = /*#__PURE__*/ (() => {
+ class CommonModule {
+ }
+ CommonModule.ɵfac = function CommonModule_Factory(t) { return new (t || CommonModule)(); };
+ CommonModule.ɵmod = /*@__PURE__*/ ɵngcc0.ɵɵdefineNgModule({ type: CommonModule });
+ CommonModule.ɵinj = /*@__PURE__*/ ɵngcc0.ɵɵdefineInjector({ providers: [
+ {
+ provide: NgLocalization,
+ useClass: NgLocaleLocalization
+ },
+ ]});
+ return CommonModule;
+ })();
+ `,
+ }),
+ );
+
+ it(
+ 'wraps class with multiple Angular native static fields',
+ testCase({
+ input: `
+ class CommonModule {
+ static ɵfac = function CommonModule_Factory(t) { return new (t || CommonModule)(); };
+ static ɵmod = /*@__PURE__*/ ɵngcc0.ɵɵdefineNgModule({ type: CommonModule });
+ static ɵinj = ɵngcc0.ɵɵdefineInjector({ providers: [
+ { provide: NgLocalization, useClass: NgLocaleLocalization },
+ ] });
+ }
+ `,
+ expected: `
+ let CommonModule = /*#__PURE__*/ (() => {
+ class CommonModule {
+ static ɵfac = function CommonModule_Factory(t) {
+ return new (t || CommonModule)();
+ };
+ static ɵmod = /*@__PURE__*/ ɵngcc0.ɵɵdefineNgModule({
+ type: CommonModule,
+ });
+ static ɵinj = ɵngcc0.ɵɵdefineInjector({
+ providers: [
+ {
+ provide: NgLocalization,
+ useClass: NgLocaleLocalization,
+ },
+ ],
+ });
+ }
+ return CommonModule;
+ })();
+ `,
+ }),
+ );
+
+ it(
+ 'wraps default exported class with pure static fields',
+ testCase({
+ input: `
+ export default class CustomComponentEffects {
+ constructor(_actions) {
+ this._actions = _actions;
+ this.doThis = this._actions;
+ }
+ }
+ CustomComponentEffects.someField = 42;
+ `,
+ expected: `
+ let CustomComponentEffects = /*#__PURE__*/ (() => {
+ class CustomComponentEffects {
+ constructor(_actions) {
+ this._actions = _actions;
+ this.doThis = this._actions;
+ }
+ }
+ CustomComponentEffects.someField = 42;
+ return CustomComponentEffects;
+ })();
+ export { CustomComponentEffects as default };
+ `,
+ }),
+ );
+ it(
+ 'supports class with empty static block and wraps pure static properties',
+ testCase({
+ input: `
+ class MyClass {
+ static {}
+ static ɵprov = 42;
+ }
+ `,
+ expected: `
+ let MyClass = /*#__PURE__*/ (() => {
+ class MyClass {
+ static {}
+ static ɵprov = 42;
+ }
+ return MyClass;
+ })();
+ `,
+ }),
+ );
+
+ it(
+ 'does not wrap class with non-empty static block',
+ testCase({
+ input: `
+ class MyClass {
+ static { console.log(1); }
+ static ɵprov = 42;
+ }
+ `,
+ expected: `
+ class MyClass {
+ static { console.log(1); }
+ static ɵprov = 42;
+ }
+ `,
+ }),
+ );
+
+ it(
+ 'wraps adjacent class declarations without interleaving',
+ testCase({
+ input: 'class A{static s=[1]}class B{static s=[2]}',
+ expected: `
+ let A = /*#__PURE__*/ (() => {
+ class A {
+ static s = [1]
+ }
+ return A;
+ })();
+ let B = /*#__PURE__*/ (() => {
+ class B {
+ static s = [2]
+ }
+ return B;
+ })();
+ `,
+ }),
+ );
+
+ it(
+ 'wraps adjacent exported class declarations without interleaving',
+ testCase({
+ input: 'export class A{static s=[1]}export class B{static s=[2]}',
+ expected: `
+ export let A = /*#__PURE__*/ (() => {
+ class A {
+ static s = [1]
+ }
+ return A;
+ })();
+ export let B = /*#__PURE__*/ (() => {
+ class B {
+ static s = [2]
+ }
+ return B;
+ })();
+ `,
+ }),
+ );
+
+ it(
+ 'wraps adjacent default export and class declarations without interleaving',
+ testCase({
+ input: 'export default class A{static s=[1]}class B{static s=[2]}',
+ expected: `
+ let A = /*#__PURE__*/ (() => {
+ class A {
+ static s = [1]
+ }
+ return A;
+ })();
+ export { A as default };
+ let B = /*#__PURE__*/ (() => {
+ class B {
+ static s = [2]
+ }
+ return B;
+ })();
+ `,
+ }),
+ );
+
+ it(
+ 'splits default export without interleaving with adjacent class declaration',
+ testCase({
+ input: 'export default class A{}class B{static s=[2]}',
+ expected: `
+ class A {}
+ export { A as default };
+ let B = /*#__PURE__*/ (() => {
+ class B {
+ static s = [2]
+ }
+ return B;
+ })();
+ `,
+ }),
+ );
+
+ it(
+ 'wraps adjacent variable class declarations without interleaving',
+ testCase({
+ input: 'let A=class A{static s=[1]};class B{static s=[2]}',
+ expected: `
+ let A = /*#__PURE__*/ (() => {
+ let A = class A {
+ static s = [1]
+ };
+ return A;
+ })();
+ let B = /*#__PURE__*/ (() => {
+ class B {
+ static s = [2]
+ }
+ return B;
+ })();
+ `,
+ }),
+ );
+});
diff --git a/packages/angular/build/src/tools/oxc/adjust-typescript-enums_oxc_spec.ts b/packages/angular/build/src/tools/oxc/adjust-typescript-enums_oxc_spec.ts
new file mode 100644
index 000000000000..82877cb3b2e3
--- /dev/null
+++ b/packages/angular/build/src/tools/oxc/adjust-typescript-enums_oxc_spec.ts
@@ -0,0 +1,382 @@
+/**
+ * @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 { format } from 'prettier';
+import { transform } from './oxc-transform';
+
+const NO_CHANGE = Symbol('NO_CHANGE');
+
+function testCase({
+ input,
+ expected,
+}: {
+ input: string;
+ expected: string | typeof NO_CHANGE;
+}): jasmine.ImplementationCallback {
+ return async () => {
+ const result = transform('test.js', input, {
+ sourcemap: false,
+ pureAnnotate: false,
+ });
+ if (!result?.code) {
+ fail('Expected oxc-transform to return a transform result.');
+ } else {
+ expect(await format(result.code, { parser: 'babel' })).toEqual(
+ await format(expected === NO_CHANGE ? input : expected, { parser: 'babel' }),
+ );
+ }
+ };
+}
+
+describe('adjust-typescript-enums oxc-transform implementation', () => {
+ it(
+ 'wraps unexported TypeScript enums',
+ testCase({
+ input: `
+ var ChangeDetectionStrategy;
+ (function (ChangeDetectionStrategy) {
+ ChangeDetectionStrategy[ChangeDetectionStrategy["OnPush"] = 0] = "OnPush";
+ ChangeDetectionStrategy[ChangeDetectionStrategy["Default"] = 1] = "Default";
+ })(ChangeDetectionStrategy || (ChangeDetectionStrategy = {}));
+ `,
+ expected: `
+ var ChangeDetectionStrategy = /*#__PURE__*/ (function (ChangeDetectionStrategy) {
+ ChangeDetectionStrategy[(ChangeDetectionStrategy["OnPush"] = 0)] = "OnPush";
+ ChangeDetectionStrategy[(ChangeDetectionStrategy["Default"] = 1)] = "Default";
+ return ChangeDetectionStrategy;
+ })(ChangeDetectionStrategy || {});
+ `,
+ }),
+ );
+
+ it(
+ 'wraps exported TypeScript enums',
+ testCase({
+ input: `
+ export var ChangeDetectionStrategy;
+ (function (ChangeDetectionStrategy) {
+ ChangeDetectionStrategy[ChangeDetectionStrategy["OnPush"] = 0] = "OnPush";
+ ChangeDetectionStrategy[ChangeDetectionStrategy["Default"] = 1] = "Default";
+ })(ChangeDetectionStrategy || (ChangeDetectionStrategy = {}));
+ `,
+ expected: `
+ export var ChangeDetectionStrategy = /*#__PURE__*/ (function (ChangeDetectionStrategy) {
+ ChangeDetectionStrategy[(ChangeDetectionStrategy["OnPush"] = 0)] = "OnPush";
+ ChangeDetectionStrategy[(ChangeDetectionStrategy["Default"] = 1)] = "Default";
+ return ChangeDetectionStrategy;
+ })(ChangeDetectionStrategy || {});
+ `,
+ }),
+ );
+
+ it(
+ 'does not wrap exported TypeScript enums from CommonJS (<5.1)',
+ testCase({
+ input: `
+ var ChangeDetectionStrategy;
+ (function (ChangeDetectionStrategy) {
+ ChangeDetectionStrategy[ChangeDetectionStrategy["OnPush"] = 0] = "OnPush";
+ ChangeDetectionStrategy[ChangeDetectionStrategy["Default"] = 1] = "Default";
+ })(ChangeDetectionStrategy = exports.ChangeDetectionStrategy || (exports.ChangeDetectionStrategy = {}));
+ `,
+ expected: NO_CHANGE,
+ }),
+ );
+
+ it(
+ 'wraps exported TypeScript enums from CommonJS (5.1+)',
+ testCase({
+ input: `
+ var ChangeDetectionStrategy;
+ (function (ChangeDetectionStrategy) {
+ ChangeDetectionStrategy[ChangeDetectionStrategy["OnPush"] = 0] = "OnPush";
+ ChangeDetectionStrategy[ChangeDetectionStrategy["Default"] = 1] = "Default";
+ })(ChangeDetectionStrategy || (exports.ChangeDetectionStrategy = ChangeDetectionStrategy = {}));
+ `,
+ expected: `
+ var ChangeDetectionStrategy = /*#__PURE__*/ (function (ChangeDetectionStrategy) {
+ ChangeDetectionStrategy[(ChangeDetectionStrategy["OnPush"] = 0)] = "OnPush";
+ ChangeDetectionStrategy[(ChangeDetectionStrategy["Default"] = 1)] = "Default";
+ return ChangeDetectionStrategy;
+ })(ChangeDetectionStrategy || (exports.ChangeDetectionStrategy = ChangeDetectionStrategy = {}));
+ `,
+ }),
+ );
+
+ it(
+ 'wraps TypeScript enums with custom numbering',
+ testCase({
+ input: `
+ export var ChangeDetectionStrategy;
+ (function (ChangeDetectionStrategy) {
+ ChangeDetectionStrategy[ChangeDetectionStrategy["OnPush"] = 5] = "OnPush";
+ ChangeDetectionStrategy[ChangeDetectionStrategy["Default"] = 8] = "Default";
+ })(ChangeDetectionStrategy || (ChangeDetectionStrategy = {}));
+ `,
+ expected: `
+ export var ChangeDetectionStrategy = /*#__PURE__*/ (function (ChangeDetectionStrategy) {
+ ChangeDetectionStrategy[(ChangeDetectionStrategy["OnPush"] = 5)] = "OnPush";
+ ChangeDetectionStrategy[(ChangeDetectionStrategy["Default"] = 8)] = "Default";
+ return ChangeDetectionStrategy;
+ })(ChangeDetectionStrategy || {});
+ `,
+ }),
+ );
+
+ it(
+ 'wraps string-based TypeScript enums',
+ testCase({
+ input: `
+ var NotificationKind;
+ (function (NotificationKind) {
+ NotificationKind["NEXT"] = "N";
+ NotificationKind["ERROR"] = "E";
+ NotificationKind["COMPLETE"] = "C";
+ })(NotificationKind || (NotificationKind = {}));
+ `,
+ expected: `
+ var NotificationKind = /*#__PURE__*/ (function (NotificationKind) {
+ NotificationKind["NEXT"] = "N";
+ NotificationKind["ERROR"] = "E";
+ NotificationKind["COMPLETE"] = "C";
+ return NotificationKind;
+ })(NotificationKind || {});
+ `,
+ }),
+ );
+
+ it(
+ 'wraps enums that were renamed due to scope hoisting',
+ testCase({
+ input: `
+ var NotificationKind$1;
+ (function (NotificationKind) {
+ NotificationKind["NEXT"] = "N";
+ NotificationKind["ERROR"] = "E";
+ NotificationKind["COMPLETE"] = "C";
+ })(NotificationKind$1 || (NotificationKind$1 = {}));
+ `,
+ expected: `
+ var NotificationKind$1 = /*#__PURE__*/ (function (NotificationKind) {
+ NotificationKind["NEXT"] = "N";
+ NotificationKind["ERROR"] = "E";
+ NotificationKind["COMPLETE"] = "C";
+ return NotificationKind;
+ })(NotificationKind$1 || {});
+ `,
+ }),
+ );
+
+ it(
+ 'maintains multi-line comments',
+ testCase({
+ input: `
+ /**
+ * Supported http methods.
+ * @deprecated use @angular/common/http instead
+ */
+ var RequestMethod;
+ (function (RequestMethod) {
+ RequestMethod[RequestMethod["Get"] = 0] = "Get";
+ RequestMethod[RequestMethod["Post"] = 1] = "Post";
+ RequestMethod[RequestMethod["Put"] = 2] = "Put";
+ RequestMethod[RequestMethod["Delete"] = 3] = "Delete";
+ RequestMethod[RequestMethod["Options"] = 4] = "Options";
+ RequestMethod[RequestMethod["Head"] = 5] = "Head";
+ RequestMethod[RequestMethod["Patch"] = 6] = "Patch";
+ })(RequestMethod || (RequestMethod = {}));
+ `,
+ expected: `
+ /**
+ * Supported http methods.
+ * @deprecated use @angular/common/http instead
+ */
+ var RequestMethod = /*#__PURE__*/ (function (RequestMethod) {
+ RequestMethod[(RequestMethod["Get"] = 0)] = "Get";
+ RequestMethod[(RequestMethod["Post"] = 1)] = "Post";
+ RequestMethod[(RequestMethod["Put"] = 2)] = "Put";
+ RequestMethod[(RequestMethod["Delete"] = 3)] = "Delete";
+ RequestMethod[(RequestMethod["Options"] = 4)] = "Options";
+ RequestMethod[(RequestMethod["Head"] = 5)] = "Head";
+ RequestMethod[(RequestMethod["Patch"] = 6)] = "Patch";
+ return RequestMethod;
+ })(RequestMethod || {});
+ `,
+ }),
+ );
+
+ it(
+ 'does not wrap TypeScript enums with side effect values',
+ testCase({
+ input: `
+ export var ChangeDetectionStrategy;
+ (function (ChangeDetectionStrategy) {
+ ChangeDetectionStrategy[ChangeDetectionStrategy["OnPush"] = 0] = console.log('foo');
+ ChangeDetectionStrategy[ChangeDetectionStrategy["Default"] = 1] = "Default";
+ })(ChangeDetectionStrategy || (ChangeDetectionStrategy = {}));
+ `,
+ expected: NO_CHANGE,
+ }),
+ );
+
+ it(
+ 'does not wrap object literals similar to TypeScript enums',
+ testCase({
+ input: `
+ const RendererStyleFlags3 = {
+ Important: 1,
+ DashCase: 2,
+ };
+ if (typeof RendererStyleFlags3 === 'object') {
+ RendererStyleFlags3[RendererStyleFlags3.Important] = 'DashCase';
+ }
+ RendererStyleFlags3[RendererStyleFlags3.Important] = 'Important';
+ `,
+ expected: NO_CHANGE,
+ }),
+ );
+
+ it(
+ 'wraps TypeScript enums',
+ testCase({
+ input: `
+ var ChangeDetectionStrategy;
+ (function (ChangeDetectionStrategy) {
+ ChangeDetectionStrategy[ChangeDetectionStrategy["OnPush"] = 0] = "OnPush";
+ ChangeDetectionStrategy[ChangeDetectionStrategy["Default"] = 1] = "Default";
+ })(ChangeDetectionStrategy || (ChangeDetectionStrategy = {}));
+ `,
+ expected: `
+ var ChangeDetectionStrategy = /*#__PURE__*/ (function (ChangeDetectionStrategy) {
+ ChangeDetectionStrategy[(ChangeDetectionStrategy["OnPush"] = 0)] = "OnPush";
+ ChangeDetectionStrategy[(ChangeDetectionStrategy["Default"] = 1)] = "Default";
+ return ChangeDetectionStrategy;
+ })(ChangeDetectionStrategy || {});
+ `,
+ }),
+ );
+
+ it(
+ 'should wrap TypeScript enums if the declaration identifier has been renamed to avoid collisions',
+ testCase({
+ input: `
+ var ChangeDetectionStrategy$1;
+ (function (ChangeDetectionStrategy) {
+ ChangeDetectionStrategy[ChangeDetectionStrategy["OnPush"] = 0] = "OnPush";
+ ChangeDetectionStrategy[ChangeDetectionStrategy["Default"] = 1] = "Default";
+ })(ChangeDetectionStrategy$1 || (ChangeDetectionStrategy$1 = {}));
+ `,
+ expected: `
+ var ChangeDetectionStrategy$1 = /*#__PURE__*/ (function (ChangeDetectionStrategy) {
+ ChangeDetectionStrategy[ChangeDetectionStrategy["OnPush"] = 0] = "OnPush";
+ ChangeDetectionStrategy[ChangeDetectionStrategy["Default"] = 1] = "Default";
+ return ChangeDetectionStrategy;
+ })(ChangeDetectionStrategy$1 || {});
+ `,
+ }),
+ );
+
+ it(
+ 'handles TypeScript enums with chained exports assignment (angular-split / shared-docs pattern)',
+ testCase({
+ input: `
+ var Area;
+ (function (a1) {
+ a1[a1["areaAfter"] = 0] = "areaAfter";
+ a1[a1["preserveOtherCategoryOrder"] = 1] = "preserveOtherCategoryOrder";
+ })(Area || (Area = exports.Area = {}));
+ `,
+ expected: `
+ var Area = /*#__PURE__*/ (function (a1) {
+ a1[(a1["areaAfter"] = 0)] = "areaAfter";
+ a1[(a1["preserveOtherCategoryOrder"] = 1)] = "preserveOtherCategoryOrder";
+ return a1;
+ })(Area || (exports.Area = {}));
+ `,
+ }),
+ );
+
+ it(
+ 'handles TypeScript enums wrapped in parentheses',
+ testCase({
+ input: `
+ var ChangeDetectionStrategy;
+ ((function (ChangeDetectionStrategy) {
+ ChangeDetectionStrategy[ChangeDetectionStrategy["OnPush"] = 0] = "OnPush";
+ ChangeDetectionStrategy[ChangeDetectionStrategy["Default"] = 1] = "Default";
+ })(ChangeDetectionStrategy || (ChangeDetectionStrategy = {})));
+ `,
+ expected: `
+ var ChangeDetectionStrategy = /*#__PURE__*/ (function (ChangeDetectionStrategy) {
+ ChangeDetectionStrategy[(ChangeDetectionStrategy["OnPush"] = 0)] = "OnPush";
+ ChangeDetectionStrategy[(ChangeDetectionStrategy["Default"] = 1)] = "Default";
+ return ChangeDetectionStrategy;
+ })(ChangeDetectionStrategy || {});
+ `,
+ }),
+ );
+
+ it(
+ 'wraps Crockford-style TypeScript enum IIFE without leaving dangling parentheses',
+ testCase({
+ input: `
+ var HDirection;
+ (function (HDirection) {
+ HDirection[HDirection['Backwards'] = -1] = 'Backwards';
+ HDirection[HDirection['Forwards'] = 1] = 'Forwards';
+ }(HDirection || (HDirection = {})));
+ const nextStatement = true;
+ `,
+ expected: `
+ var HDirection = /*#__PURE__*/ (function (HDirection) {
+ HDirection[(HDirection['Backwards'] = -1)] = 'Backwards';
+ HDirection[(HDirection['Forwards'] = 1)] = 'Forwards';
+ return HDirection;
+ }(HDirection || {}));
+
+ const nextStatement = true;
+ `,
+ }),
+ );
+
+ it(
+ 'wraps TypeScript enum IIFE with multiple nested parentheses',
+ testCase({
+ input: `
+ var Foo;
+ (((function (Foo) {
+ Foo[Foo['A'] = 0] = 'A';
+ }(Foo || (Foo = {})))));
+ `,
+ expected: `
+ var Foo = /*#__PURE__*/ (function (Foo) {
+ Foo[(Foo['A'] = 0)] = 'A';
+ return Foo;
+ }(Foo || {}));
+ `,
+ }),
+ );
+
+ it(
+ 'wraps Crockford-style TypeScript enum IIFE with chained export assignments',
+ testCase({
+ input: `
+ var Foo;
+ (function (Foo) {
+ Foo[Foo['A'] = 0] = 'A';
+ }(Foo || (Foo = exports.Foo = {})));
+ `,
+ expected: `
+ var Foo = /*#__PURE__*/ (function (Foo) {
+ Foo[(Foo['A'] = 0)] = 'A';
+ return Foo;
+ }(Foo || (exports.Foo = {})));
+ `,
+ }),
+ );
+});
diff --git a/packages/angular/build/src/tools/oxc/elide-angular-metadata_oxc_spec.ts b/packages/angular/build/src/tools/oxc/elide-angular-metadata_oxc_spec.ts
new file mode 100644
index 000000000000..f4d6c09b6c19
--- /dev/null
+++ b/packages/angular/build/src/tools/oxc/elide-angular-metadata_oxc_spec.ts
@@ -0,0 +1,213 @@
+/**
+ * @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 { transform } from './oxc-transform';
+
+function cleanCode(code: string): string {
+ return code
+ .replace(/\s+/g, '')
+ .replace(/"/g, "'")
+ .replace(/;}/g, '}')
+ .replace(/,([}\])])/g, '$1');
+}
+
+function testCase({
+ input,
+ expected,
+}: {
+ input: string;
+ expected: string;
+}): jasmine.ImplementationCallback {
+ return async () => {
+ const result = transform('test.js', input, {
+ sourcemap: false,
+ pureAnnotate: false,
+ });
+ if (!result?.code) {
+ fail('Expected oxc-transform to return a transform result.');
+ } else {
+ const actualClean = cleanCode(result.code);
+ const expectedClean = cleanCode(expected);
+ expect(actualClean).toEqual(expectedClean);
+ }
+ };
+}
+
+describe('elide-angular-metadata oxc-transform implementation', () => {
+ it(
+ 'elides pure annotated ɵsetClassMetadata',
+ testCase({
+ input: `
+ import { Component } from '@angular/core';
+ export class SomeClass {}
+ /*@__PURE__*/ (function () { i0.ɵsetClassMetadata(Clazz, [{
+ type: Component,
+ args: [{
+ selector: 'app-lazy',
+ template: 'very lazy',
+ styles: []
+ }]
+ }], null, null); })();
+ `,
+ expected: `
+ import { Component } from '@angular/core';
+ export class SomeClass {}
+ /*@__PURE__*/ (function () { void 0 })();
+ `,
+ }),
+ );
+
+ it(
+ 'elides JIT mode protected ɵsetClassMetadata',
+ testCase({
+ input: `
+ import { Component } from '@angular/core';
+ export class SomeClass {}
+ (function () { (typeof ngJitMode === "undefined" || ngJitMode) && i0.ɵsetClassMetadata(SomeClass, [{
+ type: Component,
+ args: [{
+ selector: 'app-lazy',
+ template: 'very lazy',
+ styles: []
+ }]
+ }], null, null); })();`,
+ expected: `
+ import { Component } from '@angular/core';
+ export class SomeClass {}
+ (function () { (typeof ngJitMode === "undefined" || ngJitMode) && void 0 })();`,
+ }),
+ );
+
+ it(
+ 'elides ɵsetClassMetadata inside an arrow-function-based IIFE',
+ testCase({
+ input: `
+ import { Component } from '@angular/core';
+ export class SomeClass {}
+ /*@__PURE__*/ (() => { i0.ɵsetClassMetadata(Clazz, [{
+ type: Component,
+ args: [{
+ selector: 'app-lazy',
+ template: 'very lazy',
+ styles: []
+ }]
+ }], null, null); })();
+ `,
+ expected: `
+ import { Component } from '@angular/core';
+ export class SomeClass {}
+ /*@__PURE__*/ (() => { void 0 })();
+ `,
+ }),
+ );
+
+ it(
+ 'elides pure annotated ɵsetClassMetadataAsync',
+ testCase({
+ input: `
+ import { Component } from '@angular/core';
+ export class SomeClass {}
+ /*@__PURE__*/ (function () {
+ i0.ɵsetClassMetadataAsync(SomeClass,
+ function () { return [import("./cmp-a").then(function (m) { return m.CmpA; })]; },
+ function (CmpA) { i0.ɵsetClassMetadata(SomeClass, [{
+ type: Component,
+ args: [{
+ selector: 'test-cmp',
+ standalone: true,
+ imports: [CmpA, LocalDep],
+ template: '{#defer}{/defer}',
+ }]
+ }], null, null); });
+ })();
+ `,
+ expected: `
+ import { Component } from '@angular/core';
+ export class SomeClass {}
+ /*@__PURE__*/ (function () { void 0 })();
+ `,
+ }),
+ );
+
+ it(
+ 'elides JIT mode protected ɵsetClassMetadataAsync',
+ testCase({
+ input: `
+ import { Component } from '@angular/core';
+ export class SomeClass {}
+ (function () {
+ (typeof ngJitMode === "undefined" || ngJitMode) && i0.ɵsetClassMetadataAsync(SomeClass,
+ function () { return [import("./cmp-a").then(function (m) { return m.CmpA; })]; },
+ function (CmpA) { i0.ɵsetClassMetadata(SomeClass, [{
+ type: Component,
+ args: [{
+ selector: 'test-cmp',
+ standalone: true,
+ imports: [CmpA, LocalDep],
+ template: '{#defer}{/defer}',
+ }]
+ }], null, null); });
+ })();
+ `,
+ expected: `
+ import { Component } from '@angular/core';
+ export class SomeClass {}
+ (function () { (typeof ngJitMode === "undefined" || ngJitMode) && void 0 })();
+ `,
+ }),
+ );
+
+ it(
+ 'elides arrow-function-based ɵsetClassMetadataAsync',
+ testCase({
+ input: `
+ import { Component } from '@angular/core';
+ export class SomeClass {}
+ /*@__PURE__*/ (() => {
+ i0.ɵsetClassMetadataAsync(SomeClass,
+ () => [import("./cmp-a").then(m => m.CmpA)],
+ (CmpA) => { i0.ɵsetClassMetadata(SomeClass, [{
+ type: Component,
+ args: [{
+ selector: 'test-cmp',
+ standalone: true,
+ imports: [CmpA, LocalDep],
+ template: '{#defer}{/defer}',
+ }]
+ }], null, null); });
+ })();
+ `,
+ expected: `
+ import { Component } from '@angular/core';
+ export class SomeClass {}
+ /*@__PURE__*/ (() => { void 0 })();
+ `,
+ }),
+ );
+
+ it(
+ 'elides ɵsetClassDebugInfo',
+ testCase({
+ input: `
+ import { Component } from '@angular/core';
+ class SomeClass {}
+ (() => {
+ (typeof ngDevMode === 'undefined' || ngDevMode) &&
+ i0.ɵsetClassDebugInfo(SomeClass, { className: 'SomeClass' });
+ })();
+ `,
+ expected: `
+ import { Component } from "@angular/core";
+ class SomeClass {}
+ (() => {
+ (typeof ngDevMode === "undefined" || ngDevMode) && void 0;
+ })();
+ `,
+ }),
+ );
+});
diff --git a/packages/angular/build/src/tools/oxc/oxc-transform.ts b/packages/angular/build/src/tools/oxc/oxc-transform.ts
new file mode 100644
index 000000000000..c0389a9f75a4
--- /dev/null
+++ b/packages/angular/build/src/tools/oxc/oxc-transform.ts
@@ -0,0 +1,790 @@
+/**
+ * @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 type { DecodedSourceMap } from '@ampproject/remapping';
+import { needsLinking } from '@angular/compiler-cli/linker';
+import type { BindingIdentifier, Class, Node } from '@oxc-project/types';
+import { MagicString } from 'magic-string';
+import { Visitor, parseSync } from 'oxc-parser';
+import { OxcLinker } from '../angular/linker/oxc-linker';
+
+export interface OxcTransformOptions {
+ sourcemap?: boolean;
+ sideEffects?: boolean;
+ topLevelSafeMode?: boolean;
+ pureAnnotate?: boolean;
+ link?: boolean;
+ jit?: boolean;
+ advancedOptimizations?: boolean;
+}
+
+/**
+ * A set of constructor names that are considered to be side-effect free.
+ */
+const sideEffectFreeConstructors = new Set(['InjectionToken']);
+
+/**
+ * A set of TypeScript helper function names used by the helper name matcher utility function.
+ */
+const tslibHelpers = new Set([
+ '__extends',
+ '__assign',
+ '__rest',
+ '__decorate',
+ '__param',
+ '__esDecorate',
+ '__runInitializers',
+ '__propKey',
+ '__setFunctionName',
+ '__metadata',
+ '__awaiter',
+ '__generator',
+ '__exportStar',
+ '__values',
+ '__read',
+ '__privateGet',
+ '__privateSet',
+ '__privateMethod',
+ '__addDisposableResource',
+ '__disposeResources',
+]);
+
+/**
+ * Determines whether an identifier name matches one of the TypeScript helper function names.
+ *
+ * @param name The identifier name to check.
+ * @returns True if the name matches a TypeScript helper name; otherwise, false.
+ */
+function isTslibHelperName(name: string): boolean {
+ const nameParts = name.split('$');
+ const originalName = nameParts[0];
+
+ if (nameParts.length > 2 || (nameParts.length === 2 && !/^\d+$/.test(nameParts[1]))) {
+ return false;
+ }
+
+ return tslibHelpers.has(originalName);
+}
+
+/**
+ * A set of Babel helper function names that are intended to cause side effects.
+ */
+const babelHelpers = new Set(['_defineProperty']);
+
+/**
+ * Determines whether an identifier name matches one of the Babel helper function names.
+ *
+ * @param name The identifier name to check.
+ * @returns True if the name matches a Babel helper name; otherwise, false.
+ */
+function isBabelHelperName(name: string): boolean {
+ return babelHelpers.has(name);
+}
+
+/**
+ * A set of Angular static properties that should be wrapped in pure IIFE statements.
+ */
+const angularStaticsToWrap = new Set([
+ 'ɵcmp',
+ 'ɵdir',
+ 'ɵfac',
+ 'ɵinj',
+ 'ɵmod',
+ 'ɵpipe',
+ 'ɵprov',
+ 'INJECTOR_KEY',
+]);
+
+/**
+ * A set of Angular metadata decorator functions that can be elided.
+ */
+const angularMetadataFunctions = new Set([
+ 'ɵsetClassMetadata',
+ 'ɵsetClassMetadataAsync',
+ 'ɵsetClassDebugInfo',
+]);
+
+/**
+ * A map of static properties and their matcher predicate functions to check if they
+ * can be safely elided from class declarations.
+ */
+const angularStaticsToElide: Record boolean> = {
+ 'ctorParameters'(node) {
+ return node.type === 'FunctionExpression' || node.type === 'ArrowFunctionExpression';
+ },
+ 'decorators'(node) {
+ return node.type === 'ArrayExpression';
+ },
+ 'propDecorators'(node) {
+ return node.type === 'ObjectExpression';
+ },
+};
+
+/**
+ * Determines whether an AST node is considered safe for pure evaluation (side-effect free).
+ *
+ * @param node The AST node to check.
+ * @returns True if the node is side-effect free; otherwise, false.
+ */
+function isPure(node: Node): boolean {
+ switch (node.type) {
+ case 'Identifier':
+ case 'Literal':
+ return true;
+ case 'BinaryExpression':
+ case 'LogicalExpression':
+ return isPure(node.left) && isPure(node.right);
+ case 'UnaryExpression':
+ return isPure(node.argument);
+ case 'MemberExpression':
+ return isPure(node.object) && (!node.computed || isPure(node.property));
+ case 'ObjectExpression':
+ return node.properties.every((p) => p.type === 'Property' && isPure(p.value));
+ case 'ArrayExpression':
+ return node.elements.every((e) => !e || isPure(e));
+ case 'ParenthesizedExpression':
+ return isPure(node.expression);
+ default:
+ return false;
+ }
+}
+
+/**
+ * Recursively unwraps any ParenthesizedExpression wrapper nodes to get the inner expression.
+ *
+ * @param node The potentially parenthesized AST node.
+ * @returns The inner non-parenthesized AST node.
+ */
+function unwrapParentheses(node: Node): Node {
+ while (node && node.type === 'ParenthesizedExpression') {
+ node = node.expression;
+ }
+
+ return node;
+}
+
+/**
+ * Determines whether a class static property assignment is safe to be wrapped in a pure IIFE.
+ *
+ * @param propertyName The name of the property.
+ * @param assignmentValue The AST node representing the value assigned.
+ * @param code The source code of the file.
+ * @returns True if the property can be wrapped safely; otherwise, false.
+ */
+function canWrapProperty(propertyName: string, assignmentValue: Node, code: string): boolean {
+ if (angularStaticsToWrap.has(propertyName)) {
+ return true;
+ }
+
+ const prefix = code.substring(Math.max(0, assignmentValue.start - 100), assignmentValue.start);
+ if (/(\/\*[\s\S]*?(?:@__PURE__|#__PURE__|@pureOrBreakMyCode)[\s\S]*?\*\/)\s*$/.test(prefix)) {
+ return true;
+ }
+
+ return isPure(assignmentValue);
+}
+
+/**
+ * Analyzes static properties inside a class body to determine if the class has any
+ * side-effecting static property initializers.
+ *
+ * @param classNode The Class AST node.
+ * @param code The source code of the file.
+ * @returns True if the class static properties are pure and can be wrapped; otherwise, false.
+ */
+function analyzeClassStaticProperties(classNode: Node, code: string): boolean {
+ let shouldWrap = false;
+ const body = (classNode as Class).body.body;
+ for (const element of body) {
+ if (element.type === 'PropertyDefinition') {
+ if (!element.static) {
+ continue;
+ }
+
+ const key = element.key;
+ const value = element.value;
+ if (key.type === 'Identifier' && (!value || canWrapProperty(key.name, value, code))) {
+ shouldWrap = true;
+ } else {
+ shouldWrap = false;
+ break;
+ }
+ } else if (element.type === 'StaticBlock') {
+ const blockBody = element.body;
+ if (blockBody.length === 0) {
+ continue;
+ }
+ if (blockBody.length > 1) {
+ shouldWrap = false;
+ break;
+ }
+ const expressionStatement = blockBody[0];
+ if (expressionStatement && expressionStatement.type === 'ExpressionStatement') {
+ const assignment = expressionStatement.expression;
+ if (
+ assignment &&
+ assignment.type === 'AssignmentExpression' &&
+ assignment.left.type === 'MemberExpression'
+ ) {
+ const left = assignment.left;
+ if (left.object.type === 'ThisExpression' && left.property.type === 'Identifier') {
+ if (canWrapProperty(left.property.name, assignment.right, code)) {
+ shouldWrap = true;
+ continue;
+ }
+ }
+ }
+ }
+ shouldWrap = false;
+ break;
+ }
+ }
+
+ return shouldWrap;
+}
+
+/**
+ * Executes a single-pass optimized transformation using oxc-parser and magic-string.
+ * Performs typescript enum wrapping, static class members elision/wrapping, angular metadata elision,
+ * and top-level pure function annotations.
+ *
+ * @param filename The absolute path of the file being transformed.
+ * @param code The string source content of the file.
+ * @param options Configuration options specifying which optimization steps to run.
+ * @returns The transformed code string and an optional source map.
+ */
+// eslint-disable-next-line max-lines-per-function
+export function transform(filename: string, code: string, options: OxcTransformOptions) {
+ const { program } = parseSync(filename, code, { range: true });
+ const source = new MagicString(code);
+
+ const shouldLink = options.link && needsLinking(filename, code);
+ const linker = shouldLink ? new OxcLinker(filename, code, options.jit) : undefined;
+
+ const advancedOptimizations = options.advancedOptimizations ?? true;
+ const sideEffectFree = options.sideEffects === false;
+ const topLevelSafeMode = options.topLevelSafeMode ?? false;
+ const wrapDecorators = sideEffectFree;
+ const pureAnnotate = options.pureAnnotate ?? true;
+
+ /**
+ * Scans backwards from the specified start index to check if a pure comment (e.g. `/*@__PURE__*\/`)
+ * already precedes the node.
+ *
+ * @param start The index where the node starts.
+ * @returns True if a pure comment is already present; otherwise, false.
+ */
+ function hasPureComment(start: number): boolean {
+ let i = start - 1;
+ while (i >= 0 && /\s/.test(code[i])) {
+ i--;
+ }
+ if (i < 1 || code[i] !== '/' || code[i - 1] !== '*') {
+ return false;
+ }
+ const commentEnd = i + 1;
+ const commentStart = code.lastIndexOf('/*', commentEnd);
+ if (commentStart === -1) {
+ return false;
+ }
+ const commentContent = code.substring(commentStart + 2, commentEnd - 2);
+
+ return commentContent.includes('@__PURE__') || commentContent.includes('#__PURE__');
+ }
+
+ const editedRanges: { start: number; end: number }[] = [];
+
+ /**
+ * Records a range in the source code that has been modified, preventing subsequent nested mutations.
+ *
+ * @param start The start index of the modified range.
+ * @param end The end index of the modified range.
+ */
+ function markEdited(start: number, end: number) {
+ editedRanges.push({ start, end });
+ }
+
+ /**
+ * Checks if the specified range falls inside an already modified section of code.
+ *
+ * @param start The start index of the range.
+ * @param end The end index of the range.
+ * @returns True if the range is already edited; otherwise, false.
+ */
+ function isAlreadyEdited(start: number, end: number): boolean {
+ return editedRanges.some((r) => start >= r.start && end <= r.end);
+ }
+
+ // Track function nesting depth and closest function expression wrapper
+ let functionDepth = 0;
+ let classDepth = 0;
+ const functionStack: Node[] = [];
+
+ /**
+ * Scans and rewrites TypeScript emitted enum declarations in the statement block.
+ * Wraps enum statements inside a pure IIFE assignable directly to the enum variable.
+ *
+ * @param body The array of statement AST nodes to process.
+ */
+ function adjustTypeScriptEnumsInStatements(body: Node[]) {
+ for (let i = 0; i < body.length - 1; i++) {
+ const statement = body[i];
+ let declStatement = statement;
+ if (
+ statement.type === 'ExportNamedDeclaration' &&
+ statement.declaration?.type === 'VariableDeclaration'
+ ) {
+ declStatement = statement.declaration;
+ }
+
+ if (
+ declStatement.type !== 'VariableDeclaration' ||
+ declStatement.kind !== 'var' ||
+ declStatement.declarations.length !== 1
+ ) {
+ continue;
+ }
+ const decl = declStatement.declarations[0];
+ if (decl.init || decl.id.type !== 'Identifier') {
+ continue;
+ }
+
+ const nextStatement = body[i + 1];
+ if (nextStatement.type !== 'ExpressionStatement') {
+ continue;
+ }
+
+ const nextExpr = unwrapParentheses(nextStatement.expression);
+ if (nextExpr.type !== 'CallExpression' || nextExpr.arguments.length !== 1) {
+ continue;
+ }
+
+ const arg = unwrapParentheses(nextExpr.arguments[0]);
+ if (arg.type !== 'LogicalExpression' || arg.operator !== '||') {
+ continue;
+ }
+
+ const argLeft = unwrapParentheses(arg.left);
+ if (argLeft.type !== 'Identifier' || argLeft.name !== decl.id.name) {
+ continue;
+ }
+
+ const rightCallArgument = unwrapParentheses(arg.right);
+ if (rightCallArgument.type !== 'AssignmentExpression') {
+ continue;
+ }
+
+ const callee = unwrapParentheses(nextExpr.callee);
+ if (
+ callee.type !== 'FunctionExpression' ||
+ callee.params.length !== 1 ||
+ !callee.body ||
+ callee.body.type !== 'BlockStatement'
+ ) {
+ continue;
+ }
+
+ const param = callee.params[0];
+ if (param.type !== 'Identifier') {
+ continue;
+ }
+ const paramName = (param as BindingIdentifier).name;
+
+ // Check if all statements in body are pure assignments
+ let hasElements = false;
+ let allPure = true;
+ for (const enumStatement of callee.body.body) {
+ if (enumStatement.type !== 'ExpressionStatement') {
+ allPure = false;
+ break;
+ }
+
+ const enumValueAssignment = unwrapParentheses(enumStatement.expression);
+ if (
+ enumValueAssignment.type !== 'AssignmentExpression' ||
+ !isPure(enumValueAssignment.right)
+ ) {
+ allPure = false;
+ break;
+ }
+
+ hasElements = true;
+ }
+
+ if (!allPure || !hasElements) {
+ continue;
+ }
+
+ // 1. Remove leading/trailing characters/parentheses of the expression statement
+ source.remove(nextStatement.start, nextExpr.start);
+ source.remove(nextExpr.end, nextStatement.end);
+ markEdited(nextStatement.start, nextStatement.end);
+
+ // 2. Add return statement inside IIFE body
+ source.appendRight(callee.body.end - 1, `; return ${paramName};`);
+
+ // 3. Remove `Name = ` assignment in arguments if it's a simple identifier
+ if (rightCallArgument.left.type === 'Identifier') {
+ let replacement = code.substring(
+ rightCallArgument.right.start,
+ rightCallArgument.right.end,
+ );
+ if (unwrapParentheses(rightCallArgument.right).type === 'AssignmentExpression') {
+ replacement = `(${replacement})`;
+ }
+ source.overwrite(arg.right.start, arg.right.end, replacement);
+ markEdited(arg.right.start, arg.right.end);
+ }
+
+ // 4. Move IIFE to the var initializer
+ source.move(nextExpr.start, nextExpr.end, decl.id.end);
+ source.appendLeft(decl.id.end, ' = /*#__PURE__*/ ');
+ }
+ }
+
+ /**
+ * Scans and rewrites static class member initializers in the statement block.
+ * Groups externalized class static assignments into pure wrappers or elides them when safe.
+ *
+ * @param body The array of statement AST nodes to process.
+ */
+ function adjustStaticMembersInStatements(body: Node[]) {
+ for (let i = 0; i < body.length; i++) {
+ const statement = body[i];
+ let classNode: Node | null = null;
+ let isExportDefault = false;
+ let isExportNamed = false;
+ let isVariableClass = false;
+ let classIdName = '';
+ if (statement.type === 'ClassDeclaration') {
+ classNode = statement;
+ classIdName = classNode.id?.name || '';
+ } else if (
+ statement.type === 'ExportNamedDeclaration' &&
+ statement.declaration?.type === 'ClassDeclaration'
+ ) {
+ classNode = statement.declaration;
+ classIdName = classNode.id?.name || '';
+ isExportNamed = true;
+ } else if (
+ statement.type === 'ExportDefaultDeclaration' &&
+ statement.declaration?.type === 'ClassDeclaration'
+ ) {
+ classNode = statement.declaration;
+ classIdName = classNode.id?.name || '';
+ isExportDefault = true;
+ } else if (statement.type === 'VariableDeclaration' && statement.declarations.length === 1) {
+ const decl = statement.declarations[0];
+ if (decl.init && decl.init.type === 'ClassExpression' && decl.id.type === 'Identifier') {
+ classNode = decl.init;
+ classIdName = decl.id.name;
+ isVariableClass = true;
+ }
+ }
+
+ if (!classNode || !classIdName) {
+ continue;
+ }
+
+ const wrapStatementPaths: { statement: Node; type: 'wrap' | 'decorate' | 'elide' }[] = [];
+ let hasPotentialSideEffects = false;
+
+ for (let j = i + 1; j < body.length; j++) {
+ const nextStatement = body[j];
+ if (nextStatement.type !== 'ExpressionStatement') {
+ break;
+ }
+
+ const nextExpression = nextStatement.expression;
+
+ // Case 1: __decorate(...)
+ if (nextExpression.type === 'CallExpression') {
+ if (
+ nextExpression.callee.type !== 'Identifier' ||
+ nextExpression.callee.name !== '__decorate'
+ ) {
+ break;
+ }
+
+ if (wrapDecorators) {
+ wrapStatementPaths.push({ statement: nextStatement, type: 'decorate' });
+ } else {
+ hasPotentialSideEffects = true;
+ }
+ continue;
+ }
+
+ // Case 2: AssignmentExpression
+ if (nextExpression.type !== 'AssignmentExpression') {
+ break;
+ }
+
+ const left = nextExpression.left;
+
+ if (left.type === 'Identifier') {
+ if (
+ left.name !== classIdName ||
+ nextExpression.right.type !== 'CallExpression' ||
+ nextExpression.right.callee.type !== 'Identifier' ||
+ nextExpression.right.callee.name !== '__decorate'
+ ) {
+ break;
+ }
+
+ if (wrapDecorators) {
+ wrapStatementPaths.push({ statement: nextStatement, type: 'decorate' });
+ } else {
+ hasPotentialSideEffects = true;
+ }
+ continue;
+ }
+
+ if (
+ left.type !== 'MemberExpression' ||
+ left.object.type !== 'Identifier' ||
+ left.object.name !== classIdName ||
+ left.property.type !== 'Identifier'
+ ) {
+ break;
+ }
+
+ const propertyName = left.property.name;
+ const assignmentValue = nextExpression.right;
+
+ if (angularStaticsToElide[propertyName]?.(assignmentValue)) {
+ wrapStatementPaths.push({ statement: nextStatement, type: 'elide' });
+ } else if (canWrapProperty(propertyName, assignmentValue, code)) {
+ wrapStatementPaths.push({ statement: nextStatement, type: 'wrap' });
+ } else {
+ hasPotentialSideEffects = true;
+ }
+ }
+
+ // Check class body static properties
+ const shouldWrapClassStaticProperties = analyzeClassStaticProperties(classNode, code);
+
+ // Perform elisions immediately
+ for (const item of wrapStatementPaths) {
+ if (item.type === 'elide') {
+ source.remove(item.statement.start, item.statement.end);
+ markEdited(item.statement.start, item.statement.end);
+ }
+ }
+
+ const activeWrapPaths = wrapStatementPaths.filter(
+ (p) => p.type === 'wrap' || p.type === 'decorate',
+ );
+
+ if (
+ !hasPotentialSideEffects &&
+ (activeWrapPaths.length > 0 || shouldWrapClassStaticProperties)
+ ) {
+ const lastStatement =
+ activeWrapPaths.length > 0
+ ? activeWrapPaths[activeWrapPaths.length - 1].statement
+ : classNode;
+
+ if (isExportDefault) {
+ // 1. Remove `export default `
+ source.overwrite(statement.start, classNode.start, '');
+ // 2. Wrap in IIFE
+ source.appendRight(classNode.start, `let ${classIdName} = /*#__PURE__*/ (() => {\n`);
+ source.appendLeft(
+ lastStatement.end,
+ `\nreturn ${classIdName};\n})();\nexport { ${classIdName} as default };`,
+ );
+ } else if (isExportNamed) {
+ // 1. Export is kept, turn `class` into `let ClassName = IIFE`
+ source.appendRight(classNode.start, `let ${classIdName} = /*#__PURE__*/ (() => {\n`);
+ source.appendLeft(lastStatement.end, `\nreturn ${classIdName};\n})();`);
+ } else if (isVariableClass) {
+ // Wrap class inside init: `/*#__PURE__*/ (() => { let ClassName = class ClassName {}; return ClassName; })()`
+ source.appendRight(classNode.start, `/*#__PURE__*/ (() => {\nlet ${classIdName} = `);
+ const terminator = activeWrapPaths.length === 0 ? ';' : '';
+ const iifeClosing = activeWrapPaths.length === 0 ? '})()' : '})();';
+ source.appendLeft(
+ lastStatement.end,
+ `${terminator}\nreturn ${classIdName};\n${iifeClosing}`,
+ );
+ } else {
+ // Standard ClassDeclaration
+ source.appendRight(classNode.start, `let ${classIdName} = /*#__PURE__*/ (() => {\n`);
+ source.appendLeft(lastStatement.end, `\nreturn ${classIdName};\n})();`);
+ }
+
+ markEdited(statement.start, lastStatement.end);
+
+ // Fast-forward outer loop index to skip the statements we wrapped
+ i += wrapStatementPaths.length;
+ } else if (isExportDefault && !hasPotentialSideEffects) {
+ // Splitting default export even when not wrapped
+ source.overwrite(statement.start, classNode.start, '');
+ source.appendLeft(classNode.end, `\nexport { ${classIdName} as default };`);
+ markEdited(statement.start, classNode.end);
+ }
+ }
+ }
+
+ const visitor = new Visitor({
+ ClassDeclaration(node) {
+ classDepth++;
+ },
+ 'ClassDeclaration:exit'() {
+ classDepth--;
+ },
+ ClassExpression(node) {
+ classDepth++;
+ },
+ 'ClassExpression:exit'() {
+ classDepth--;
+ },
+ FunctionDeclaration(node) {
+ functionDepth++;
+ functionStack.push(node);
+ },
+ 'FunctionDeclaration:exit'() {
+ functionDepth--;
+ functionStack.pop();
+ },
+ FunctionExpression(node) {
+ functionDepth++;
+ functionStack.push(node);
+ },
+ 'FunctionExpression:exit'() {
+ functionDepth--;
+ functionStack.pop();
+ },
+ ArrowFunctionExpression(node) {
+ functionDepth++;
+ functionStack.push(node);
+ },
+ 'ArrowFunctionExpression:exit'() {
+ functionDepth--;
+ functionStack.pop();
+ },
+ 'Program:exit'(node) {
+ if (advancedOptimizations) {
+ adjustTypeScriptEnumsInStatements(node.body);
+ adjustStaticMembersInStatements(node.body);
+ }
+ },
+ 'BlockStatement:exit'(node) {
+ if (advancedOptimizations) {
+ adjustTypeScriptEnumsInStatements(node.body);
+ adjustStaticMembersInStatements(node.body);
+ }
+ },
+ CallExpression(node) {
+ if (isAlreadyEdited(node.start, node.end)) {
+ return;
+ }
+
+ if (linker) {
+ const linkedCode = linker.linkCallExpression(node);
+ if (linkedCode !== undefined) {
+ source.overwrite(node.start, node.end, linkedCode);
+ markEdited(node.start, node.end);
+
+ return;
+ }
+ }
+
+ if (!advancedOptimizations) {
+ return;
+ }
+
+ // 1. Elide Angular Metadata check
+ let calleeName: string | undefined;
+ if (node.callee.type === 'Identifier') {
+ calleeName = node.callee.name;
+ } else if (
+ node.callee.type === 'MemberExpression' &&
+ node.callee.property.type === 'Identifier'
+ ) {
+ calleeName = node.callee.property.name;
+ }
+
+ if (calleeName && angularMetadataFunctions.has(calleeName)) {
+ const parentFunc = functionStack[functionStack.length - 1];
+ if (
+ parentFunc &&
+ (parentFunc.type === 'FunctionExpression' ||
+ parentFunc.type === 'ArrowFunctionExpression')
+ ) {
+ source.overwrite(node.start, node.end, 'void 0');
+ markEdited(node.start, node.end);
+
+ return;
+ }
+ }
+
+ // 2. Mark Top-Level Pure Functions check
+ if (!pureAnnotate || functionDepth > 0 || classDepth > 0 || topLevelSafeMode) {
+ return;
+ }
+
+ const callee = unwrapParentheses(node.callee);
+ if (
+ (callee.type === 'FunctionExpression' || callee.type === 'ArrowFunctionExpression') &&
+ node.arguments.length !== 0
+ ) {
+ return;
+ }
+
+ if (
+ callee.type === 'Identifier' &&
+ (isTslibHelperName(callee.name) || isBabelHelperName(callee.name))
+ ) {
+ return;
+ }
+
+ if (!hasPureComment(node.start)) {
+ source.appendLeft(node.start, '/*#__PURE__*/ ');
+ }
+ },
+ NewExpression(node) {
+ if (
+ !advancedOptimizations ||
+ !pureAnnotate ||
+ functionDepth > 0 ||
+ classDepth > 0 ||
+ isAlreadyEdited(node.start, node.end)
+ ) {
+ return;
+ }
+
+ if (!topLevelSafeMode) {
+ if (!hasPureComment(node.start)) {
+ source.appendLeft(node.start, '/*#__PURE__*/ ');
+ }
+
+ return;
+ }
+
+ const callee = node.callee;
+ if (callee.type === 'Identifier' && sideEffectFreeConstructors.has(callee.name)) {
+ if (!hasPureComment(node.start)) {
+ source.appendLeft(node.start, '/*#__PURE__*/ ');
+ }
+ }
+ },
+ });
+
+ visitor.visit(program);
+
+ let map: DecodedSourceMap | undefined;
+ if (options.sourcemap) {
+ const rawMap = source.generateDecodedMap({ hires: true, source: filename });
+ map = { ...rawMap, version: 3 };
+ }
+
+ return {
+ code: source.toString(),
+ map,
+ };
+}
diff --git a/packages/angular/build/src/tools/oxc/oxc-transform_spec.ts b/packages/angular/build/src/tools/oxc/oxc-transform_spec.ts
new file mode 100644
index 000000000000..d1b02f6a1bdc
--- /dev/null
+++ b/packages/angular/build/src/tools/oxc/oxc-transform_spec.ts
@@ -0,0 +1,80 @@
+/**
+ * @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 { transform } from './oxc-transform';
+
+describe('oxc-transform', () => {
+ describe('sourcemaps', () => {
+ it('should generate a decoded sourcemap when sourcemap option is enabled', () => {
+ const input = 'var result = new SomeClass();';
+ const result = transform('test.js', input, { sourcemap: true });
+
+ expect(result.map).toBeDefined();
+ expect(result.map?.version).toBe(3);
+ expect(result.map?.sources).toContain('test.js');
+ expect(result.map?.mappings.length).toBeGreaterThan(0);
+ });
+ });
+
+ describe('linking and unified passes', () => {
+ const componentInput = `
+ import * as i0 from "@angular/core";
+ export class MyComponent {}
+ MyComponent.ɵcmp = i0.ɵɵngDeclareComponent({
+ minVersion: "12.0.0",
+ version: "14.0.0",
+ ngImport: i0,
+ type: MyComponent,
+ isStandalone: true,
+ selector: "my-cmp",
+ template: "Hello"
+ });
+ `;
+
+ it('should link partial component declarations when link option is enabled', () => {
+ const result = transform('test.js', componentInput, { link: true });
+ expect(result.code).toContain('i0.ɵɵdefineComponent');
+ expect(result.code).not.toContain('i0.ɵɵngDeclareComponent');
+ });
+
+ it('should not link partial component declarations when link option is disabled', () => {
+ const result = transform('test.js', componentInput, { link: false });
+ expect(result.code).not.toContain('i0.ɵɵdefineComponent');
+ expect(result.code).toContain('i0.ɵɵngDeclareComponent');
+ });
+
+ it('should perform linking and advanced optimizations simultaneously in a single pass', () => {
+ const input = `
+ import * as i0 from "@angular/core";
+ export class MyComponent {
+ static create() {
+ return new MyComponent();
+ }
+ }
+ MyComponent.ɵcmp = i0.ɵɵngDeclareComponent({
+ minVersion: "12.0.0",
+ version: "14.0.0",
+ ngImport: i0,
+ type: MyComponent,
+ isStandalone: true,
+ selector: "my-cmp",
+ template: "Hello"
+ });
+ `;
+
+ const result = transform('test.js', input, {
+ link: true,
+ advancedOptimizations: true,
+ topLevelSafeMode: true,
+ });
+ expect(result.code).toContain('i0.ɵɵdefineComponent');
+ expect(result.code).not.toContain('i0.ɵɵngDeclareComponent');
+ expect(result.code).toContain('let MyComponent = /*#__PURE__*/ (() => {');
+ });
+ });
+});
diff --git a/packages/angular/build/src/tools/oxc/pure-toplevel-functions_oxc_spec.ts b/packages/angular/build/src/tools/oxc/pure-toplevel-functions_oxc_spec.ts
new file mode 100644
index 000000000000..0b91cc98aa45
--- /dev/null
+++ b/packages/angular/build/src/tools/oxc/pure-toplevel-functions_oxc_spec.ts
@@ -0,0 +1,202 @@
+/**
+ * @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 { transform } from './oxc-transform';
+
+function cleanCode(code: string): string {
+ return code
+ .replace(/\s+/g, '')
+ .replace(/"/g, "'")
+ .replace(/;}/g, '}')
+ .replace(/,([}\])])/g, '$1');
+}
+
+function testCase({
+ input,
+ expected,
+ options,
+}: {
+ input: string;
+ expected: string;
+ options?: { topLevelSafeMode: boolean };
+}): jasmine.ImplementationCallback {
+ return async () => {
+ const result = transform('test.js', input, {
+ sourcemap: false,
+ topLevelSafeMode: options?.topLevelSafeMode,
+ });
+ if (!result?.code) {
+ fail('Expected oxc-transform to return a transform result.');
+ } else {
+ const actualClean = cleanCode(result.code);
+ const expectedClean = cleanCode(expected);
+ expect(actualClean).toEqual(expectedClean);
+ }
+ };
+}
+
+function testCaseNoChange(input: string): jasmine.ImplementationCallback {
+ return testCase({ input, expected: input });
+}
+
+describe('pure-toplevel-functions oxc-transform implementation', () => {
+ it(
+ 'annotates top-level new expressions',
+ testCase({
+ input: 'var result = new SomeClass();',
+ expected: 'var result = /*#__PURE__*/ new SomeClass();',
+ }),
+ );
+
+ it(
+ 'annotates top-level function calls',
+ testCase({
+ input: 'var result = someCall();',
+ expected: 'var result = /*#__PURE__*/ someCall();',
+ }),
+ );
+
+ it(
+ 'annotates top-level IIFE assignments with no arguments',
+ testCase({
+ input: 'var SomeClass = (function () { function SomeClass() { } return SomeClass; })();',
+ expected:
+ 'var SomeClass = /*#__PURE__*/(function () { function SomeClass() { } return SomeClass; })();',
+ }),
+ );
+
+ it(
+ 'annotates top-level arrow-function-based IIFE assignments with no arguments',
+ testCase({
+ input: 'var SomeClass = (() => { function SomeClass() { } return SomeClass; })();',
+ expected:
+ 'var SomeClass = /*#__PURE__*/(() => { function SomeClass() { } return SomeClass; })();',
+ }),
+ );
+
+ it(
+ 'does not annotate top-level IIFE assignments with arguments',
+ testCaseNoChange(
+ 'var SomeClass = (function () { function SomeClass() { } return SomeClass; })(abc);',
+ ),
+ );
+
+ it(
+ 'does not annotate top-level arrow-function-based IIFE assignments with arguments',
+ testCaseNoChange(
+ 'var SomeClass = (() => { function SomeClass() { } return SomeClass; })(abc);',
+ ),
+ );
+
+ it(
+ 'does not annotate call expressions inside function declarations',
+ testCaseNoChange('function funcDecl() { const result = someFunction(); }'),
+ );
+
+ it(
+ 'does not annotate call expressions inside function expressions',
+ testCaseNoChange('const foo = function funcDecl() { const result = someFunction(); }'),
+ );
+
+ it(
+ 'does not annotate call expressions inside arrow functions',
+ testCaseNoChange('const foo = () => { const result = someFunction(); }'),
+ );
+
+ it(
+ 'does not annotate new expressions inside function declarations',
+ testCaseNoChange('function funcDecl() { const result = new SomeClass(); }'),
+ );
+
+ it(
+ 'does not annotate new expressions inside function expressions',
+ testCaseNoChange('const foo = function funcDecl() { const result = new SomeClass(); }'),
+ );
+
+ it(
+ 'does not annotate new expressions inside arrow functions',
+ testCaseNoChange('const foo = () => { const result = new SomeClass(); }'),
+ );
+
+ it(
+ 'does not annotate TypeScript helper functions (tslib)',
+ testCaseNoChange(`
+ class LanguageState {}
+ __decorate([
+ __metadata("design:type", Function),
+ __metadata("design:paramtypes", [Object]),
+ __metadata("design:returntype", void 0)
+ ], LanguageState.prototype, "checkLanguage", null);
+ `),
+ );
+
+ it(
+ 'does not annotate _defineProperty function',
+ testCaseNoChange(`
+ class LanguageState {}
+ _defineProperty(
+ LanguageState,
+ 'property',
+ 'value'
+ );
+ `),
+ );
+
+ it(
+ 'does not annotate object literal methods',
+ testCaseNoChange(`
+ const literal = {
+ method() {
+ var newClazz = new Clazz();
+ }
+ };
+ `),
+ );
+
+ it(
+ 'annotates helper functions with non-numeric suffixes',
+ testCase({
+ input: 'var result = __decorate$foo();',
+ expected: 'var result = /*#__PURE__*/ __decorate$foo();',
+ }),
+ );
+
+ it(
+ 'does not annotate helper functions with numeric suffixes',
+ testCaseNoChange('var result = __decorate$1();'),
+ );
+
+ describe('topLevelSafeMode: true', () => {
+ it(
+ 'annotates top-level `new InjectionToken` expressions',
+ testCase({
+ input: `const result = new InjectionToken('abc');`,
+ expected: `const result = /*#__PURE__*/ new InjectionToken('abc');`,
+ options: { topLevelSafeMode: true },
+ }),
+ );
+
+ it(
+ 'does not annotate other top-level `new` expressions',
+ testCase({
+ input: 'const result = new SomeClass();',
+ expected: 'const result = new SomeClass();',
+ options: { topLevelSafeMode: true },
+ }),
+ );
+
+ it(
+ 'does not annotate top-level function calls',
+ testCase({
+ input: 'const result = someCall();',
+ expected: 'const result = someCall();',
+ options: { topLevelSafeMode: true },
+ }),
+ );
+ });
+});
diff --git a/packages/angular/build/src/tools/oxc/types.d.ts b/packages/angular/build/src/tools/oxc/types.d.ts
new file mode 100644
index 000000000000..aa1f40580491
--- /dev/null
+++ b/packages/angular/build/src/tools/oxc/types.d.ts
@@ -0,0 +1,20 @@
+/**
+ * @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
+ */
+
+declare module 'istanbul-lib-instrument' {
+ export interface Instrumenter {
+ instrumentSync(code: string, filename: string, inputSourceMap?: object): string;
+ lastSourceMap(): object | undefined;
+ }
+
+ export function createInstrumenter(options?: {
+ produceSourceMap?: boolean;
+ esModules?: boolean;
+ coverageVariable?: string;
+ }): Instrumenter;
+}
diff --git a/packages/angular/build/src/tools/sass/rebasing-importer.ts b/packages/angular/build/src/tools/sass/rebasing-importer.ts
index 15c94a25aeef..5d2a4ddc2267 100644
--- a/packages/angular/build/src/tools/sass/rebasing-importer.ts
+++ b/packages/angular/build/src/tools/sass/rebasing-importer.ts
@@ -6,8 +6,8 @@
* found in the LICENSE file at https://angular.dev/license
*/
-import { RawSourceMap } from '@ampproject/remapping';
-import MagicString from 'magic-string';
+import type { DecodedSourceMap } from '@ampproject/remapping';
+import { MagicString } from 'magic-string';
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { basename, dirname, extname, join, relative } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
@@ -44,7 +44,7 @@ abstract class UrlRebasingImporter implements Importer<'sync'> {
*/
constructor(
private entryDirectory: string,
- private rebaseSourceMaps?: Map,
+ private rebaseSourceMaps?: Map,
) {}
abstract canonicalize(url: string, options: { fromImport: boolean }): URL | null;
@@ -95,12 +95,15 @@ abstract class UrlRebasingImporter implements Importer<'sync'> {
contents = updatedContents.toString();
if (this.rebaseSourceMaps) {
// Generate an intermediate source map for the rebasing changes
- const map = updatedContents.generateMap({
+ const map = updatedContents.generateDecodedMap({
hires: 'boundary',
includeContent: true,
source: canonicalUrl.href,
});
- this.rebaseSourceMaps.set(canonicalUrl.href, map as RawSourceMap);
+ this.rebaseSourceMaps.set(canonicalUrl.href, {
+ ...map,
+ version: 3,
+ } satisfies DecodedSourceMap);
}
}
@@ -134,7 +137,7 @@ export class RelativeUrlRebasingImporter extends UrlRebasingImporter {
constructor(
entryDirectory: string,
private directoryCache = new Map(),
- rebaseSourceMaps?: Map,
+ rebaseSourceMaps?: Map,
) {
super(entryDirectory, rebaseSourceMaps);
}
@@ -322,7 +325,7 @@ export class ModuleUrlRebasingImporter extends RelativeUrlRebasingImporter {
constructor(
entryDirectory: string,
directoryCache: Map,
- rebaseSourceMaps: Map | undefined,
+ rebaseSourceMaps: Map | undefined,
private finder: (specifier: string, options: CanonicalizeContext) => URL | null,
) {
super(entryDirectory, directoryCache, rebaseSourceMaps);
@@ -349,7 +352,7 @@ export class LoadPathsUrlRebasingImporter extends RelativeUrlRebasingImporter {
constructor(
entryDirectory: string,
directoryCache: Map,
- rebaseSourceMaps: Map | undefined,
+ rebaseSourceMaps: Map | undefined,
private loadPaths: Iterable,
) {
super(entryDirectory, directoryCache, rebaseSourceMaps);
diff --git a/packages/angular/build/src/tools/sass/worker.ts b/packages/angular/build/src/tools/sass/worker.ts
index 1a2e1184892f..e4167a3d1c69 100644
--- a/packages/angular/build/src/tools/sass/worker.ts
+++ b/packages/angular/build/src/tools/sass/worker.ts
@@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.dev/license
*/
-import mergeSourceMaps, { RawSourceMap } from '@ampproject/remapping';
+import mergeSourceMaps, { type DecodedSourceMap, type RawSourceMap } from '@ampproject/remapping';
import { dirname } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { MessagePort, receiveMessageOnPort } from 'node:worker_threads';
@@ -89,7 +89,7 @@ export default async function renderSassStylesheet(
let warnings: SerializableWarningMessage[] | undefined;
try {
const directoryCache = new Map();
- const rebaseSourceMaps = options.sourceMap ? new Map() : undefined;
+ const rebaseSourceMaps = options.sourceMap ? new Map() : undefined;
if (importerChannel) {
// When a custom importer function is present, the importer request must be proxied
// back to the main thread where it can be executed.
diff --git a/packages/angular/build/src/tools/vite/middlewares/assets-middleware.ts b/packages/angular/build/src/tools/vite/middlewares/assets-middleware.ts
index e0074625afe0..02f54756eaff 100644
--- a/packages/angular/build/src/tools/vite/middlewares/assets-middleware.ts
+++ b/packages/angular/build/src/tools/vite/middlewares/assets-middleware.ts
@@ -7,12 +7,14 @@
*/
import { lookup as lookupMimeType } from 'mrmime';
-import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
import type { ServerResponse } from 'node:http';
import { extname } from 'node:path';
-import type { Connect, ViteDevServer } from 'vite';
+import type { Connect, ViteDevServer } from 'vite' with {
+ 'resolution-mode': 'import',
+};
import { ResultFile } from '../../../builders/application/results';
+import { calculateHash } from '../../../utils/hash';
import { AngularMemoryOutputFiles, AngularOutputAssets, pathnameWithoutBasePath } from '../utils';
export interface ComponentStyleRecord {
@@ -48,7 +50,7 @@ export function createAngularAssetsMiddleware(
// This is a workaround to serve extensionless, CSS, JS and TS files without Vite transformations.
if (!extension || JS_TS_REGEXP.test(extension) || CSS_PREPROCESSOR_REGEXP.test(extension)) {
const contents = readFileSync(asset.source);
- const etag = `W/${createHash('sha256').update(contents).digest('hex')}`;
+ const etag = `W/${calculateHash(contents)}`;
if (checkAndHandleEtag(req, res, etag)) {
return;
}
@@ -236,7 +238,7 @@ export function createBuildAssetsMiddleware(
const contents =
outputFile.origin === 'memory' ? outputFile.contents : readHandler(outputFile.inputPath);
- const etag = `W/${createHash('sha256').update(contents).digest('hex')}`;
+ const etag = `W/${calculateHash(contents)}`;
if (checkAndHandleEtag(req, res, etag)) {
return;
}
diff --git a/packages/angular/build/src/tools/vite/middlewares/base-middleware.ts b/packages/angular/build/src/tools/vite/middlewares/base-middleware.ts
index 00198e03061a..a9df78fd2981 100644
--- a/packages/angular/build/src/tools/vite/middlewares/base-middleware.ts
+++ b/packages/angular/build/src/tools/vite/middlewares/base-middleware.ts
@@ -7,7 +7,9 @@
*/
import type { IncomingMessage, ServerResponse } from 'node:http';
-import type { Connect } from 'vite';
+import type { Connect } from 'vite' with {
+ 'resolution-mode': 'import',
+};
import { addLeadingSlash } from '../../../utils/url';
/**
diff --git a/packages/angular/build/src/tools/vite/middlewares/chrome-devtools-middleware.ts b/packages/angular/build/src/tools/vite/middlewares/chrome-devtools-middleware.ts
index 43c5bed2edf9..fdeff2eec705 100644
--- a/packages/angular/build/src/tools/vite/middlewares/chrome-devtools-middleware.ts
+++ b/packages/angular/build/src/tools/vite/middlewares/chrome-devtools-middleware.ts
@@ -10,7 +10,9 @@ import assert from 'node:assert';
import { randomUUID } from 'node:crypto';
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
-import type { Connect } from 'vite';
+import type { Connect } from 'vite' with {
+ 'resolution-mode': 'import',
+};
type DevToolsJson = {
workspace: {
diff --git a/packages/angular/build/src/tools/vite/middlewares/component-middleware.ts b/packages/angular/build/src/tools/vite/middlewares/component-middleware.ts
index 0e02616cb384..29360d78af55 100644
--- a/packages/angular/build/src/tools/vite/middlewares/component-middleware.ts
+++ b/packages/angular/build/src/tools/vite/middlewares/component-middleware.ts
@@ -6,7 +6,9 @@
* found in the LICENSE file at https://angular.dev/license
*/
-import type { Connect, ViteDevServer } from 'vite';
+import type { Connect, ViteDevServer } from 'vite' with {
+ 'resolution-mode': 'import',
+};
import { pathnameWithoutBasePath } from '../utils';
const ANGULAR_COMPONENT_PREFIX = '/@ng/component';
diff --git a/packages/angular/build/src/tools/vite/middlewares/headers-middleware.ts b/packages/angular/build/src/tools/vite/middlewares/headers-middleware.ts
index 46d4a88f0543..c7f9408cec4a 100644
--- a/packages/angular/build/src/tools/vite/middlewares/headers-middleware.ts
+++ b/packages/angular/build/src/tools/vite/middlewares/headers-middleware.ts
@@ -7,7 +7,9 @@
*/
import type { ServerResponse } from 'node:http';
-import type { Connect, ViteDevServer } from 'vite';
+import type { Connect, ViteDevServer } from 'vite' with {
+ 'resolution-mode': 'import',
+};
/**
* Creates a middleware for adding custom headers.
diff --git a/packages/angular/build/src/tools/vite/middlewares/host-check-middleware.ts b/packages/angular/build/src/tools/vite/middlewares/host-check-middleware.ts
index 8561354812b3..a4d49f4a9eeb 100644
--- a/packages/angular/build/src/tools/vite/middlewares/host-check-middleware.ts
+++ b/packages/angular/build/src/tools/vite/middlewares/host-check-middleware.ts
@@ -7,7 +7,9 @@
*/
import type { IncomingMessage, ServerResponse } from 'node:http';
-import type { Connect } from 'vite';
+import type { Connect } from 'vite' with {
+ 'resolution-mode': 'import',
+};
export function patchHostValidationMiddleware(middlewares: Connect.Server): void {
const entry = middlewares.stack.find(
diff --git a/packages/angular/build/src/tools/vite/middlewares/html-fallback-middleware.ts b/packages/angular/build/src/tools/vite/middlewares/html-fallback-middleware.ts
index cd52b8a7904f..dbef050ac4c0 100644
--- a/packages/angular/build/src/tools/vite/middlewares/html-fallback-middleware.ts
+++ b/packages/angular/build/src/tools/vite/middlewares/html-fallback-middleware.ts
@@ -7,7 +7,9 @@
*/
import type { ServerResponse } from 'node:http';
-import type { Connect } from 'vite';
+import type { Connect } from 'vite' with {
+ 'resolution-mode': 'import',
+};
import { lookupMimeTypeFromRequest } from '../utils';
const ALLOWED_FALLBACK_METHODS = Object.freeze(['GET', 'HEAD']);
diff --git a/packages/angular/build/src/tools/vite/middlewares/index-html-middleware.ts b/packages/angular/build/src/tools/vite/middlewares/index-html-middleware.ts
index 7959ccb7ec03..7767d970ee42 100644
--- a/packages/angular/build/src/tools/vite/middlewares/index-html-middleware.ts
+++ b/packages/angular/build/src/tools/vite/middlewares/index-html-middleware.ts
@@ -7,7 +7,9 @@
*/
import { extname } from 'node:path';
-import type { Connect, ViteDevServer } from 'vite';
+import type { Connect, ViteDevServer } from 'vite' with {
+ 'resolution-mode': 'import',
+};
import { AngularMemoryOutputFiles, pathnameWithoutBasePath } from '../utils';
export function createAngularIndexHtmlMiddleware(
diff --git a/packages/angular/build/src/tools/vite/middlewares/ssr-middleware.ts b/packages/angular/build/src/tools/vite/middlewares/ssr-middleware.ts
index a26fa8e5e257..78458624c4db 100644
--- a/packages/angular/build/src/tools/vite/middlewares/ssr-middleware.ts
+++ b/packages/angular/build/src/tools/vite/middlewares/ssr-middleware.ts
@@ -11,7 +11,9 @@ import type {
ɵgetOrCreateAngularServerApp as getOrCreateAngularServerApp,
} from '@angular/ssr';
import type { ServerResponse } from 'node:http';
-import type { Connect, ViteDevServer } from 'vite';
+import type { Connect, ViteDevServer } from 'vite' with {
+ 'resolution-mode': 'import',
+};
import {
isSsrNodeRequestHandler,
isSsrRequestHandler,
diff --git a/packages/angular/build/src/tools/vite/plugins/angular-memory-plugin.ts b/packages/angular/build/src/tools/vite/plugins/angular-memory-plugin.ts
index be00e3437f27..c6dc243385a8 100644
--- a/packages/angular/build/src/tools/vite/plugins/angular-memory-plugin.ts
+++ b/packages/angular/build/src/tools/vite/plugins/angular-memory-plugin.ts
@@ -10,7 +10,10 @@ import assert from 'node:assert';
import { readFile } from 'node:fs/promises';
import { dirname, join, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
-import type { Plugin } from 'vite';
+import type * as Vite from 'vite' with {
+ 'resolution-mode': 'import',
+};
+import { removeSourceMappingURL } from '../../../utils/source-map';
import { AngularMemoryOutputFiles } from '../utils';
interface AngularMemoryPluginOptions {
@@ -27,9 +30,9 @@ const FILE_PROTOCOL = 'file:';
export async function createAngularMemoryPlugin(
options: AngularMemoryPluginOptions,
-): Promise {
+): Promise {
const { virtualProjectRoot, outputFiles, external } = options;
- const { normalizePath } = await import('vite');
+ const { normalizePath } = (await import('vite' as string)) as typeof Vite;
return {
name: 'vite:angular-memory',
@@ -102,7 +105,7 @@ export async function createAngularMemoryPlugin(
return {
// Remove source map URL comments from the code if a sourcemap is present.
// Vite will inline and add an additional sourcemap URL for the sourcemap.
- code: mapContents ? code.replace(/^\/\/# sourceMappingURL=[^\r\n]*/gm, '') : code,
+ code: mapContents ? removeSourceMappingURL(code) : code,
map: mapContents && Buffer.from(mapContents).toString('utf-8'),
};
},
diff --git a/packages/angular/build/src/tools/vite/plugins/id-prefix-plugin.ts b/packages/angular/build/src/tools/vite/plugins/id-prefix-plugin.ts
index 5e543734b863..74cd03179a7e 100644
--- a/packages/angular/build/src/tools/vite/plugins/id-prefix-plugin.ts
+++ b/packages/angular/build/src/tools/vite/plugins/id-prefix-plugin.ts
@@ -6,7 +6,9 @@
* found in the LICENSE file at https://angular.dev/license
*/
-import type { Plugin } from 'vite';
+import type { Plugin } from 'vite' with {
+ 'resolution-mode': 'import',
+};
// NOTE: the implementation for this Vite plugin is roughly based on:
// https://github.com/MilanKovacic/vite-plugin-externalize-dependencies
@@ -27,11 +29,7 @@ export function createRemoveIdPrefixPlugin(externals: string[]): Plugin {
return;
}
- const escapedExternals = externals.map((e) => escapeRegexSpecialChars(e) + '(?:/.+)?');
- const prefixedExternalRegex = new RegExp(
- `${resolvedConfig.base}${VITE_ID_PREFIX}(${escapedExternals.join('|')})`,
- 'g',
- );
+ const transformFn = createTransformer(resolvedConfig.base, externals);
// @ts-expect-error: Property 'push' does not exist on type 'readonly Plugin[]'
// Reasoning:
@@ -40,15 +38,34 @@ export function createRemoveIdPrefixPlugin(externals: string[]): Plugin {
// AFTER the import-analysis.
resolvedConfig.plugins.push({
name: 'angular-plugin-remove-id-prefix-transform',
- transform: (code: string) => {
- // don't do anything when code does not contain the Vite prefix
- if (!code.includes(VITE_ID_PREFIX)) {
- return code;
- }
-
- return code.replace(prefixedExternalRegex, (_, externalName) => externalName);
- },
+ transform: transformFn,
});
},
};
}
+
+/**
+ * Creates a transform function that removes the Vite ID prefix from externals.
+ * @param base The base path of the application.
+ * @param externals The external package names.
+ * @returns A function that transforms code by removing the Vite ID prefix.
+ */
+export function createTransformer(base: string, externals: string[]): (code: string) => string {
+ // The path suffix is bounded so that a match can never extend past the end of an
+ // import specifier string literal. With a greedy `.+`, minified (single-line) code
+ // would let the first match consume the remainder of the line, leaving all later
+ // `/@id/` occurrences on that line unstripped.
+ const escapedExternals = externals.map((e) => escapeRegexSpecialChars(e) + '(?:/[^\'"`\\s]+)?');
+
+ const prefixedExternalRegex = new RegExp(
+ `${base}${VITE_ID_PREFIX}(${escapedExternals.join('|')})`,
+ 'g',
+ );
+
+ return (code: string) => {
+ return code.includes(VITE_ID_PREFIX)
+ ? code.replace(prefixedExternalRegex, (_, externalName) => externalName)
+ : // don't do anything when code does not contain the Vite prefix
+ code;
+ };
+}
diff --git a/packages/angular/build/src/tools/vite/plugins/id-prefix-plugin_spec.ts b/packages/angular/build/src/tools/vite/plugins/id-prefix-plugin_spec.ts
new file mode 100644
index 000000000000..37d1bdf61fe6
--- /dev/null
+++ b/packages/angular/build/src/tools/vite/plugins/id-prefix-plugin_spec.ts
@@ -0,0 +1,67 @@
+/**
+ * @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 { createTransformer } from './id-prefix-plugin';
+
+describe('createTransformer', () => {
+ it('should strip the prefix from every occurrence on a single (minified) line', () => {
+ const transform = createTransformer('/', [
+ '@angular/common',
+ '@angular/common/http',
+ '@angular/core',
+ '@angular/router',
+ ]);
+
+ const minified =
+ 'import{a}from"/@id/@angular/common/http";' +
+ 'import{b}from"/@id/@angular/router";' +
+ 'import{c}from"/@id/@angular/core";';
+
+ expect(transform(minified)).toBe(
+ 'import{a}from"@angular/common/http";' +
+ 'import{b}from"@angular/router";' +
+ 'import{c}from"@angular/core";',
+ );
+ });
+
+ it('should strip the prefix from an external with a deep import path', () => {
+ const transform = createTransformer('/', ['@angular/common']);
+
+ expect(transform('import{h}from"/@id/@angular/common/http";')).toBe(
+ 'import{h}from"@angular/common/http";',
+ );
+ });
+
+ it('should strip the prefix when a non-root base is configured', () => {
+ const transform = createTransformer('/app/', ['@angular/router']);
+
+ expect(transform('import{r}from"/app/@id/@angular/router";')).toBe(
+ 'import{r}from"@angular/router";',
+ );
+ });
+
+ it('should strip the prefix from multi-line (unminified) code', () => {
+ const transform = createTransformer('/', ['@angular/common', '@angular/router']);
+
+ const code =
+ 'import { CommonModule } from "/@id/@angular/common";\n' +
+ 'import { Router } from "/@id/@angular/router";\n';
+
+ expect(transform(code)).toBe(
+ 'import { CommonModule } from "@angular/common";\n' +
+ 'import { Router } from "@angular/router";\n',
+ );
+ });
+
+ it('should not modify imports that are not configured externals', () => {
+ const transform = createTransformer('/', ['@angular/router']);
+
+ const code = 'import{x}from"/@id/some-other-package";';
+ expect(transform(code)).toBe(code);
+ });
+});
diff --git a/packages/angular/build/src/tools/vite/plugins/setup-middlewares-plugin.ts b/packages/angular/build/src/tools/vite/plugins/setup-middlewares-plugin.ts
index 5d20d5c705ac..78e16304f01d 100644
--- a/packages/angular/build/src/tools/vite/plugins/setup-middlewares-plugin.ts
+++ b/packages/angular/build/src/tools/vite/plugins/setup-middlewares-plugin.ts
@@ -6,7 +6,9 @@
* found in the LICENSE file at https://angular.dev/license
*/
-import type { Connect, Plugin } from 'vite';
+import type { Connect, Plugin } from 'vite' with {
+ 'resolution-mode': 'import',
+};
import {
ComponentStyleRecord,
angularHtmlFallbackMiddleware,
diff --git a/packages/angular/build/src/tools/vite/plugins/ssr-ssl-plugin.ts b/packages/angular/build/src/tools/vite/plugins/ssr-ssl-plugin.ts
index a32c87a604de..ceb1f9630b7f 100644
--- a/packages/angular/build/src/tools/vite/plugins/ssr-ssl-plugin.ts
+++ b/packages/angular/build/src/tools/vite/plugins/ssr-ssl-plugin.ts
@@ -7,7 +7,9 @@
*/
import { getCACertificates, setDefaultCACertificates } from 'node:tls';
-import type { Plugin } from 'vite';
+import type { Plugin } from 'vite' with {
+ 'resolution-mode': 'import',
+};
export function createAngularServerSideSSLPlugin(): Plugin {
return {
diff --git a/packages/angular/build/src/tools/vite/plugins/ssr-transform-plugin.ts b/packages/angular/build/src/tools/vite/plugins/ssr-transform-plugin.ts
index 90d183acde02..b857cc3cde74 100644
--- a/packages/angular/build/src/tools/vite/plugins/ssr-transform-plugin.ts
+++ b/packages/angular/build/src/tools/vite/plugins/ssr-transform-plugin.ts
@@ -7,10 +7,12 @@
*/
import remapping, { SourceMapInput } from '@ampproject/remapping';
-import type { Plugin } from 'vite';
+import type * as Vite from 'vite' with {
+ 'resolution-mode': 'import',
+};
-export async function createAngularSsrTransformPlugin(workspaceRoot: string): Promise {
- const { normalizePath } = await import('vite');
+export async function createAngularSsrTransformPlugin(workspaceRoot: string): Promise {
+ const { normalizePath } = (await import('vite' as string)) as typeof Vite;
return {
name: 'vite:angular-ssr-transform',
diff --git a/packages/angular/build/src/tools/vite/utils.ts b/packages/angular/build/src/tools/vite/utils.ts
index 7250fd93ceb7..dc7094b8ffe4 100644
--- a/packages/angular/build/src/tools/vite/utils.ts
+++ b/packages/angular/build/src/tools/vite/utils.ts
@@ -9,10 +9,11 @@
import { lookup as lookupMimeType } from 'mrmime';
import { builtinModules, isBuiltin } from 'node:module';
import { extname } from 'node:path';
-import type { DepOptimizationConfig } from 'vite';
+import type { DepOptimizationConfig } from 'vite' with {
+ 'resolution-mode': 'import',
+};
import type { ExternalResultMetadata } from '../esbuild/bundler-execution-result';
import { JavaScriptTransformer } from '../esbuild/javascript-transformer';
-import { getFeatureSupport } from '../esbuild/utils';
export type AngularMemoryOutputFiles = Map<
string,
@@ -41,75 +42,63 @@ export function lookupMimeTypeFromRequest(url: string): string | undefined {
return extension && lookupMimeType(extension);
}
-type ViteEsBuildPlugin = NonNullable<
- NonNullable['plugins']
->[0];
-
-export type EsbuildLoaderOption = Exclude<
- DepOptimizationConfig['esbuildOptions'],
+export type RolldownLoaderOption = Exclude<
+ DepOptimizationConfig['rolldownOptions'],
undefined
->['loader'];
+>['moduleTypes'];
export function getDepOptimizationConfig({
+ target,
disabled,
exclude,
include,
- target,
- zoneless,
prebundleTransformer,
- ssr,
loader,
thirdPartySourcemaps,
define = {},
}: {
+ target: string[];
disabled: boolean;
exclude: string[];
include: string[];
- target: string[];
prebundleTransformer: JavaScriptTransformer;
- ssr: boolean;
- zoneless: boolean;
- loader?: EsbuildLoaderOption;
+ loader?: RolldownLoaderOption;
thirdPartySourcemaps: boolean;
define: Record | undefined;
}): DepOptimizationConfig {
- const plugins: ViteEsBuildPlugin[] = [
- {
- name: `angular-vite-optimize-deps${ssr ? '-ssr' : ''}${
- thirdPartySourcemaps ? '-vendor-sourcemap' : ''
- }`,
- setup(build) {
- build.onLoad({ filter: /\.[cm]?js$/ }, async (args) => {
- return {
- contents: await prebundleTransformer.transformFile(args.path),
- loader: 'js',
- };
- });
- },
- },
- ];
-
- return {
+ const config: DepOptimizationConfig = {
// Exclude any explicitly defined dependencies (currently build defined externals)
exclude,
// NB: to disable the deps optimizer, set optimizeDeps.noDiscovery to true and optimizeDeps.include as undefined.
// Include all implict dependencies from the external packages internal option
include: disabled ? undefined : include,
noDiscovery: disabled,
- // Add an esbuild plugin to run the Angular linker on dependencies
- esbuildOptions: {
- // Set esbuild supported targets.
- target,
- supported: getFeatureSupport(zoneless),
- plugins,
- loader,
- define: {
- ...define,
- 'ngServerMode': `${ssr}`,
+ rolldownOptions: {
+ transform: {
+ target,
+ define,
},
- resolveExtensions: ['.mjs', '.js', '.cjs'],
+ moduleTypes: loader,
+ resolve: {
+ extensions: ['.mjs', '.js', '.cjs'],
+ },
+ plugins: [
+ {
+ name: `angular-vite-optimize-deps${thirdPartySourcemaps ? '-vendor-sourcemap' : ''}`,
+ load: {
+ filter: { id: /\.[cm]?js$/ },
+ async handler(id: string) {
+ const code = await prebundleTransformer.transformFile(id);
+
+ return { code: Buffer.from(code).toString('utf-8') };
+ },
+ },
+ },
+ ],
},
};
+
+ return config;
}
export interface DevServerExternalResultMetadata {
diff --git a/packages/angular/build/src/utils/bundle-calculator.ts b/packages/angular/build/src/utils/bundle-calculator.ts
index 3349a8a40830..71a75dafde02 100644
--- a/packages/angular/build/src/utils/bundle-calculator.ts
+++ b/packages/angular/build/src/utils/bundle-calculator.ts
@@ -152,6 +152,8 @@ function calculateSizes(budget: BudgetEntry, stats: BudgetStats): Size[] {
}
abstract class Calculator {
+ private assetMap?: ReadonlyMap;
+
constructor(
protected budget: BudgetEntry,
protected chunks: BudgetChunk[],
@@ -167,15 +169,24 @@ abstract class Calculator {
return 0;
}
+ if (!this.assetMap) {
+ const map = new Map();
+ for (const asset of this.assets) {
+ map.set(asset.name, asset.size);
+ }
+ this.assetMap = map;
+ }
+ const assetMap = this.assetMap;
+
return chunk.files
.filter((file) => !file.endsWith('.map'))
.map((file) => {
- const asset = this.assets.find((asset) => asset.name === file);
- if (!asset) {
+ const assetSize = assetMap.get(file);
+ if (assetSize === undefined) {
throw new Error(`Could not find asset for file: ${file}`);
}
- return asset.size;
+ return assetSize;
})
.reduce((l, r) => l + r, 0);
}
diff --git a/packages/angular/build/src/utils/debug-id.ts b/packages/angular/build/src/utils/debug-id.ts
new file mode 100644
index 000000000000..a03fd571ec72
--- /dev/null
+++ b/packages/angular/build/src/utils/debug-id.ts
@@ -0,0 +1,106 @@
+/**
+ * @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 { createHash } from 'node:crypto';
+
+/**
+ * Fixed RFC 4122 namespace UUID used to derive deterministic UUIDv5 build/Debug IDs.
+ * Treated as 16 raw bytes when fed into the SHA-1 of `namespace || name`.
+ *
+ * The exact value is arbitrary but must remain stable across releases so that
+ * the same source map content always yields the same Debug ID.
+ */
+const ANGULAR_BUILD_NAMESPACE = Buffer.from('6f9619ff8b86d011b42d00cf4fc964ff', 'hex');
+
+/**
+ * Generates a deterministic UUIDv5 (RFC 4122 §4.3) Debug ID from the given name bytes.
+ *
+ * Determinism is recommended by the ECMA-426 "Source Map Debug ID" proposal
+ * (https://github.com/tc39/ecma426/blob/main/proposals/debug-id.md) so that the
+ * produced artifacts are stable across builds with the same source content.
+ *
+ * @param name Bytes that uniquely identify the artifact (typically the source map content).
+ * @returns A canonical UUIDv5 string (lowercase, hyphenated).
+ */
+export function generateDebugId(name: string | Uint8Array): string {
+ const sha = createHash('sha1').update(ANGULAR_BUILD_NAMESPACE).update(name).digest();
+
+ // Set version (5) in the high nibble of byte 6.
+ sha[6] = (sha[6] & 0x0f) | 0x50;
+ // Set RFC 4122 variant bits (10xx) in byte 8.
+ sha[8] = (sha[8] & 0x3f) | 0x80;
+
+ const h = sha.toString('hex');
+
+ return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20, 32)}`;
+}
+
+/** Pattern matching an existing `//# debugId=` comment at the end of the file. */
+const DEBUG_ID_COMMENT = /(\n\/\/# debugId=[^\r\n]*)(\n\/\/# sourceMappingURL=[^\r\n]*)?(\s*)$/;
+
+/** Pattern matching the `//# sourceMappingURL=` comment, used to position the debug-id line. */
+const SOURCE_MAPPING_URL_COMMENT = /\n\/\/# sourceMappingURL=[^\r\n]*\s*$/;
+
+/**
+ * Inserts (or replaces) a `//# debugId=` comment in the given JavaScript text.
+ *
+ * Per ECMA-426, the comment must appear within the last 5 lines and SHOULD be
+ * placed immediately above any `//# sourceMappingURL=` comment so that existing
+ * tools that only consult the final line still find the source-map URL.
+ */
+export function injectDebugIdIntoJs(text: string, id: string): string {
+ const comment = `//# debugId=${id}`;
+
+ // Replace any existing debugId comment to keep the operation idempotent.
+ if (DEBUG_ID_COMMENT.test(text)) {
+ return text.replace(DEBUG_ID_COMMENT, (_, p1, p2, p3) => `\n${comment}${p2 || ''}${p3 || ''}`);
+ }
+
+ if (SOURCE_MAPPING_URL_COMMENT.test(text)) {
+ return text.replace(SOURCE_MAPPING_URL_COMMENT, (match) => `\n${comment}${match}`);
+ }
+
+ // No source map reference; append at the very end on its own line.
+ return text.endsWith('\n') ? `${text}${comment}\n` : `${text}\n${comment}\n`;
+}
+
+/**
+ * Sets the top-level `debugId` field on a JSON source map.
+ *
+ * Per ECMA-426, source maps embed the same Debug ID under a `debugId` key so
+ * that consumers can pair a generated file with its source map without relying
+ * on URL/path conventions.
+ */
+export function injectDebugIdIntoSourceMap(json: string, id: string): string {
+ let parsed: Record;
+ try {
+ parsed = JSON.parse(json) as Record;
+ } catch {
+ // Source map is malformed; do not corrupt it further.
+ return json;
+ }
+
+ if (parsed['debugId'] === id) {
+ return json;
+ }
+
+ parsed['debugId'] = id;
+
+ // Preserve existing pretty-print indentation when the source map is formatted.
+ const indent = json.match(/^[^{]*{\r?\n([ \t]+)/)?.[1];
+
+ return JSON.stringify(parsed, null, indent);
+}
+
+/**
+ * Strips any existing `debugId` field from the source map JSON string to restore
+ * the original JSON contents for deterministic/idempotent hashing.
+ */
+export function stripDebugIdFromSourceMap(json: string): string {
+ return json.replace(/,\s*"debugId"\s*:\s*"[^"]*"|\s*"debugId"\s*:\s*"[^"]*"\s*,?/g, '');
+}
diff --git a/packages/angular/build/src/utils/debug-id_spec.ts b/packages/angular/build/src/utils/debug-id_spec.ts
new file mode 100644
index 000000000000..f254e28f6eee
--- /dev/null
+++ b/packages/angular/build/src/utils/debug-id_spec.ts
@@ -0,0 +1,137 @@
+/**
+ * @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 {
+ generateDebugId,
+ injectDebugIdIntoJs,
+ injectDebugIdIntoSourceMap,
+ stripDebugIdFromSourceMap,
+} from './debug-id';
+
+const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
+
+describe('debug-id', () => {
+ describe('generateDebugId', () => {
+ it('produces a canonical UUIDv5 string', () => {
+ expect(generateDebugId('hello')).toMatch(UUID);
+ expect(generateDebugId(new TextEncoder().encode('hello'))).toMatch(UUID);
+ });
+
+ it('is deterministic for identical inputs', () => {
+ expect(generateDebugId('same content')).toBe(generateDebugId('same content'));
+ });
+
+ it('differs for different inputs', () => {
+ expect(generateDebugId('one')).not.toBe(generateDebugId('two'));
+ });
+ });
+
+ describe('injectDebugIdIntoJs', () => {
+ const id = '11111111-2222-5333-9444-555555555555';
+
+ it('inserts the debugId comment immediately above sourceMappingURL', () => {
+ const text = 'console.log(1);\n//# sourceMappingURL=foo.js.map\n';
+ const result = injectDebugIdIntoJs(text, id);
+ expect(result).toBe(
+ 'console.log(1);\n//# debugId=11111111-2222-5333-9444-555555555555\n//# sourceMappingURL=foo.js.map\n',
+ );
+ });
+
+ it('appends the comment when no sourceMappingURL is present', () => {
+ const text = 'console.log(1);\n';
+ const result = injectDebugIdIntoJs(text, id);
+ expect(result).toBe('console.log(1);\n//# debugId=11111111-2222-5333-9444-555555555555\n');
+ });
+
+ it('appends a leading newline when input does not end with one', () => {
+ const text = 'console.log(1);';
+ const result = injectDebugIdIntoJs(text, id);
+ expect(result).toBe('console.log(1);\n//# debugId=11111111-2222-5333-9444-555555555555\n');
+ });
+
+ it('replaces an existing debugId comment (idempotent)', () => {
+ const original =
+ 'console.log(1);\n//# debugId=00000000-0000-5000-8000-000000000000\n//# sourceMappingURL=foo.js.map\n';
+ const result = injectDebugIdIntoJs(original, id);
+ expect(result).toBe(
+ 'console.log(1);\n//# debugId=11111111-2222-5333-9444-555555555555\n//# sourceMappingURL=foo.js.map\n',
+ );
+ // Re-running with the same id is a no-op.
+ expect(injectDebugIdIntoJs(result, id)).toBe(result);
+ });
+
+ it('does not replace a debugId comment that is inside a string or template literal', () => {
+ const text =
+ 'const t = `\n//# debugId=00000000-0000-5000-8000-000000000000\n`;\n//# sourceMappingURL=foo.js.map\n';
+ const result = injectDebugIdIntoJs(text, id);
+ expect(result).toBe(
+ 'const t = `\n//# debugId=00000000-0000-5000-8000-000000000000\n`;\n' +
+ `//# debugId=${id}\n` +
+ '//# sourceMappingURL=foo.js.map\n',
+ );
+ });
+ });
+
+ describe('injectDebugIdIntoSourceMap', () => {
+ const id = '11111111-2222-5333-9444-555555555555';
+
+ it('adds a top-level debugId field', () => {
+ const map = JSON.stringify({ version: 3, sources: ['a.ts'], mappings: '' });
+ const updated = JSON.parse(injectDebugIdIntoSourceMap(map, id));
+ expect(updated.debugId).toBe(id);
+ expect(updated.version).toBe(3);
+ });
+
+ it('overwrites an existing debugId field', () => {
+ const map = JSON.stringify({ version: 3, debugId: 'old', mappings: '' });
+ const updated = JSON.parse(injectDebugIdIntoSourceMap(map, id));
+ expect(updated.debugId).toBe(id);
+ });
+
+ it('returns the original input when debugId already matches', () => {
+ const map =
+ '{\n "version": 3,\n "debugId": "11111111-2222-5333-9444-555555555555",\n "mappings": ""\n}';
+ expect(injectDebugIdIntoSourceMap(map, id)).toBe(map);
+ });
+
+ it('preserves indentation when updating a pretty-printed source map', () => {
+ const map = '{\n\t"version": 3,\n\t"mappings": ""\n}';
+ expect(injectDebugIdIntoSourceMap(map, id)).toBe(
+ '{\n\t"version": 3,\n\t"mappings": "",\n\t"debugId": "11111111-2222-5333-9444-555555555555"\n}',
+ );
+ });
+
+ it('returns the original input when JSON is malformed', () => {
+ const malformed = '{ this is not json';
+ expect(injectDebugIdIntoSourceMap(malformed, id)).toBe(malformed);
+ });
+ });
+
+ describe('stripDebugIdFromSourceMap', () => {
+ it('removes the debugId field from pretty-printed source maps', () => {
+ const original = '{\n\t"version": 3,\n\t"mappings": ""\n}';
+ const updated =
+ '{\n\t"version": 3,\n\t"mappings": "",\n\t"debugId": "11111111-2222-5333-9444-555555555555"\n}';
+ expect(stripDebugIdFromSourceMap(updated)).toBe(original);
+ });
+
+ it('removes the debugId field from minified source maps', () => {
+ const original = '{"version":3,"mappings":""}';
+ const updated =
+ '{"version":3,"mappings":"","debugId":"11111111-2222-5333-9444-555555555555"}';
+ expect(stripDebugIdFromSourceMap(updated)).toBe(original);
+ });
+
+ it('removes the debugId field when it is the first property', () => {
+ const original = '{\n\t"version": 3,\n\t"mappings": ""\n}';
+ const updated =
+ '{\n\t"debugId": "11111111-2222-5333-9444-555555555555",\n\t"version": 3,\n\t"mappings": ""\n}';
+ expect(stripDebugIdFromSourceMap(updated)).toBe(original);
+ });
+ });
+});
diff --git a/packages/angular/build/src/utils/environment-options.ts b/packages/angular/build/src/utils/environment-options.ts
index b6a486f8f528..206467e929a0 100644
--- a/packages/angular/build/src/utils/environment-options.ts
+++ b/packages/angular/build/src/utils/environment-options.ts
@@ -106,7 +106,7 @@ export const allowMinify = debugOptimize.minify;
* Allows using Rolldown for chunk optimization instead of Rollup.
* This is useful for debugging and testing scenarios.
*/
-export const useRolldownChunks = parseTristate(process.env['NG_BUILD_CHUNKS_ROLLDOWN']) ?? false;
+export const useRolldownChunks = parseTristate(process.env['NG_BUILD_CHUNKS_ROLLDOWN']) ?? true;
/**
* Some environments, like CircleCI which use Docker report a number of CPUs by the host and not the count of available.
@@ -194,6 +194,13 @@ export const useComponentTemplateHmr = parseTristate(process.env['NG_HMR_TEMPLAT
*/
export const usePartialSsrBuild = parseTristate(process.env['NG_BUILD_PARTIAL_SSR']) === true;
+/**
+ * When `NG_BUILD_BABEL_LINKER` is enabled (`1` or `true`), the Babel-based
+ * Angular Linker (`@angular/compiler-cli/linker/babel`) will be used instead of the
+ * default OXC in-place linker.
+ */
+export const useBabelLinker = parseTristate(process.env['NG_BUILD_BABEL_LINKER']) === true;
+
const bazelBinDirectory = process.env['BAZEL_BINDIR'];
const bazelExecRoot = process.env['JS_BINARY__EXECROOT'];
@@ -201,3 +208,19 @@ export const bazelEsbuildPluginPath =
bazelBinDirectory && bazelExecRoot
? process.env['NG_INTERNAL_ESBUILD_PLUGINS_DO_NOT_USE']
: undefined;
+
+/**
+ * The persistent cache store configuration to use.
+ * Managed by the `NG_BUILD_CACHE_STORE` environment variable.
+ * - 'lmdb': Forces the use of LMDB.
+ * - 'sqlite': Forces the use of SQLite.
+ * - undefined / 'auto' / other: Automatically uses LMDB and falls back to SQLite.
+ */
+export const persistentCacheStoreSetting = (() => {
+ const env = process.env['NG_BUILD_CACHE_STORE']?.trim().toLowerCase();
+ if (env === 'lmdb' || env === 'sqlite') {
+ return env;
+ }
+
+ return undefined;
+})();
diff --git a/packages/angular/build/src/utils/hash.ts b/packages/angular/build/src/utils/hash.ts
new file mode 100644
index 000000000000..fdc5565afbb1
--- /dev/null
+++ b/packages/angular/build/src/utils/hash.ts
@@ -0,0 +1,74 @@
+/**
+ * @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 assert from 'node:assert';
+import type { XXHashAPI } from 'xxhash-wasm';
+
+let xxhashInstance: XXHashAPI | undefined;
+let xxhashPromise: Promise | undefined;
+
+/**
+ * Initializes the xxHash WASM instance early to ensure synchronous hashing uses xxHash.
+ */
+export async function initializeHash(): Promise {
+ if (xxhashInstance) {
+ return;
+ }
+
+ xxhashPromise ??= import('xxhash-wasm').then((m) => m.default());
+ xxhashInstance = await xxhashPromise;
+}
+
+function getXxhash(): XXHashAPI {
+ assert(
+ xxhashInstance,
+ 'Hash utility must be initialized by awaiting `initializeHash()` before use.',
+ );
+
+ return xxhashInstance;
+}
+
+/**
+ * Calculates a fast 64-bit non-cryptographic hash of the provided content.
+ * Suitable for cache keys, ETags, and change detection.
+ */
+export function calculateHash(data: string | Uint8Array): string {
+ const instance = getXxhash();
+
+ if (typeof data === 'string') {
+ return instance.h64ToString(data);
+ }
+
+ return instance.h64Raw(data).toString(16).padStart(16, '0');
+}
+
+export interface ContentHasher {
+ update(data: string | Uint8Array): ContentHasher;
+ digest(): string;
+}
+
+/**
+ * Creates a streaming 64-bit non-cryptographic content hasher.
+ */
+export function createContentHash(): ContentHasher {
+ const instance = getXxhash();
+ const hasher = instance.create64();
+
+ const contentHasher: ContentHasher = {
+ update(data: string | Uint8Array): ContentHasher {
+ hasher.update(data);
+
+ return contentHasher;
+ },
+ digest(): string {
+ return hasher.digest().toString(16).padStart(16, '0');
+ },
+ };
+
+ return contentHasher;
+}
diff --git a/packages/angular/build/src/utils/hash_spec.ts b/packages/angular/build/src/utils/hash_spec.ts
new file mode 100644
index 000000000000..77fcbc909403
--- /dev/null
+++ b/packages/angular/build/src/utils/hash_spec.ts
@@ -0,0 +1,60 @@
+/**
+ * @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 { calculateHash, createContentHash, initializeHash } from './hash';
+
+describe('hash utility', () => {
+ beforeAll(async () => {
+ await initializeHash();
+ });
+
+ it('should calculate identical 64-bit hex hash for string and Buffer with same content', () => {
+ const text = 'export const message = "hello world";';
+ const buffer = Buffer.from(text, 'utf-8');
+
+ const stringHash = calculateHash(text);
+ const bufferHash = calculateHash(buffer);
+
+ expect(typeof stringHash).toBe('string');
+ expect(stringHash.length).toBe(16);
+ expect(stringHash).toBe(bufferHash);
+ });
+
+ it('should calculate different hashes for different contents', () => {
+ const hash1 = calculateHash('const a = 1;');
+ const hash2 = calculateHash('const a = 2;');
+
+ expect(hash1).not.toBe(hash2);
+ });
+
+ it('should support streaming multi-part hashing matching combined single-shot hash', () => {
+ const part1 = 'header: ';
+ const part2 = 'body content: ';
+ const part3 = 'footer';
+
+ const hasher = createContentHash();
+ hasher.update(part1);
+ hasher.update(part2);
+ hasher.update(Buffer.from(part3, 'utf-8'));
+ const streamingHash = hasher.digest();
+
+ const singleShotHash = calculateHash(part1 + part2 + part3);
+
+ expect(streamingHash.length).toBe(16);
+ expect(streamingHash).toBe(singleShotHash);
+ });
+
+ it('should handle Uint8Array chunks in streaming hasher', () => {
+ const hasher = createContentHash();
+ hasher.update(new Uint8Array([1, 2, 3, 4])).update('some-string');
+ const digest = hasher.digest();
+
+ expect(typeof digest).toBe('string');
+ expect(digest.length).toBe(16);
+ });
+});
diff --git a/packages/angular/build/src/utils/i18n-options.ts b/packages/angular/build/src/utils/i18n-options.ts
index 822683bef03d..6a288622d053 100644
--- a/packages/angular/build/src/utils/i18n-options.ts
+++ b/packages/angular/build/src/utils/i18n-options.ts
@@ -28,6 +28,7 @@ export interface I18nOptions {
flatOutput?: boolean;
readonly shouldInline: boolean;
hasDefinedSourceLocale?: boolean;
+ localizeVersion?: string;
}
function normalizeTranslationFileOption(
diff --git a/packages/angular/build/src/utils/index-file/augment-index-html.ts b/packages/angular/build/src/utils/index-file/augment-index-html.ts
index 5e9d05d1bd56..becbee4be002 100644
--- a/packages/angular/build/src/utils/index-file/augment-index-html.ts
+++ b/packages/angular/build/src/utils/index-file/augment-index-html.ts
@@ -11,6 +11,12 @@ import { extname } from 'node:path';
import { htmlRewritingStream } from './html-rewriting-stream';
import { VALID_SELF_CLOSING_TAGS } from './valid-self-closing-tags';
+/**
+ * RegExp to check if a URL is resolvable.
+ * A URL is resolvable if it is absolute (starting with http/https) or relative (starting with `./`, `../`, or `/`).
+ */
+const RESOLVABLE_URL_REGEXP = /^(?:\.{0,2}\/|https?:\/\/)/i;
+
export type LoadOutputFileFunctionType = (file: string) => Promise;
export type CrossOriginValue = 'none' | 'anonymous' | 'use-credentials';
@@ -168,7 +174,9 @@ export async function augmentIndexHtml(
keyA.localeCompare(keyB),
);
for (const [url, integrityHash] of sortedEntries) {
- integrity[generateUrl(url, deployUrl)] = integrityHash;
+ const resolvedUrl = generateUrl(url, deployUrl);
+ const key = RESOLVABLE_URL_REGEXP.test(resolvedUrl) ? resolvedUrl : `./${resolvedUrl}`;
+ integrity[key] = integrityHash;
}
const importMapJson = JSON.stringify({ integrity }).replace(/${importMapJson}`;
@@ -268,9 +276,11 @@ export async function augmentIndexHtml(
if (isString(baseHref)) {
updateAttribute(tag, 'href', baseHref);
}
+
if (subResourceIntegrityTag) {
rewriter.emitRaw(subResourceIntegrityTag);
}
+
break;
case 'link':
if (readAttribute(tag, 'rel') === 'preconnect') {
diff --git a/packages/angular/build/src/utils/index-file/augment-index-html_spec.ts b/packages/angular/build/src/utils/index-file/augment-index-html_spec.ts
index f2801ab3202a..df292b8771a3 100644
--- a/packages/angular/build/src/utils/index-file/augment-index-html_spec.ts
+++ b/packages/angular/build/src/utils/index-file/augment-index-html_spec.ts
@@ -468,10 +468,10 @@ describe('augment-index-html', () => {
const match = content.match(/`);
@@ -166,12 +166,12 @@ describe('auto-csp', () => {
// Loader script for main.js and main2.js appear after 'foo' and before 'bar'.
expect(result).toMatch(
// eslint-disable-next-line max-len
- /console.log\('foo'\);<\/script>\s*
+ Some text
+