From 15a669c1efdc8ac18507232d6cb29794c82b94cc Mon Sep 17 00:00:00 2001
From: Charles Lyding <19598772+clydin@users.noreply.github.com>
Date: Thu, 14 Dec 2023 14:55:09 -0500
Subject: [PATCH] feat(@angular-devkit/build-angular): allowing control of
index HTML initial preload generation
The long-form variant of the `index` option for the `application` builder now supports
an addition sub-option named `preloadInitial`. This new option is a boolean option that controls
the generation of initial preload related link elements in the generated index HTML file
for the application. Preload related link elements include `preload`, `modulepreload`,
and `preconnect` link rels for initial JavaScript and stylesheet application files.
---
.../src/builders/application/options.ts | 2 +
.../src/builders/application/schema.json | 5 +
.../application/tests/options/index_spec.ts | 209 ++++++++++++++++++
.../src/tools/esbuild/index-html-generator.ts | 2 +-
4 files changed, 217 insertions(+), 1 deletion(-)
create mode 100644 packages/angular_devkit/build_angular/src/builders/application/tests/options/index_spec.ts
diff --git a/packages/angular_devkit/build_angular/src/builders/application/options.ts b/packages/angular_devkit/build_angular/src/builders/application/options.ts
index 490576b318..45d38a5a42 100644
--- a/packages/angular_devkit/build_angular/src/builders/application/options.ts
+++ b/packages/angular_devkit/build_angular/src/builders/application/options.ts
@@ -220,6 +220,8 @@ export async function normalizeOptions(
styles: options.styles ?? [],
}),
transformer: extensions?.indexHtmlTransformer,
+ // Preload initial defaults to true
+ preloadInitial: typeof options.index !== 'object' || (options.index.preloadInitial ?? true),
};
}
diff --git a/packages/angular_devkit/build_angular/src/builders/application/schema.json b/packages/angular_devkit/build_angular/src/builders/application/schema.json
index a54224011e..07ae5febec 100644
--- a/packages/angular_devkit/build_angular/src/builders/application/schema.json
+++ b/packages/angular_devkit/build_angular/src/builders/application/schema.json
@@ -416,6 +416,11 @@
"minLength": 1,
"default": "index.html",
"description": "The output path of the application's generated HTML index file. The full provided path will be used and will be considered relative to the application's configured output path."
+ },
+ "preloadInitial": {
+ "type": "boolean",
+ "default": true,
+ "description": "Generates 'preload', `modulepreload', and 'preconnect' link elements for initial application files and resources."
}
},
"required": ["input"]
diff --git a/packages/angular_devkit/build_angular/src/builders/application/tests/options/index_spec.ts b/packages/angular_devkit/build_angular/src/builders/application/tests/options/index_spec.ts
new file mode 100644
index 0000000000..5b6fac44a4
--- /dev/null
+++ b/packages/angular_devkit/build_angular/src/builders/application/tests/options/index_spec.ts
@@ -0,0 +1,209 @@
+/**
+ * @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.io/license
+ */
+
+import { buildApplication } from '../../index';
+import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup';
+
+describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => {
+ describe('Option: "index"', () => {
+ beforeEach(async () => {
+ // Application code is not needed for index tests
+ await harness.writeFile('src/main.ts', 'console.log("TEST");');
+ });
+
+ describe('short form syntax', () => {
+ it('should not generate an output file when false', async () => {
+ harness.useTarget('build', {
+ ...BASE_OPTIONS,
+ index: false,
+ });
+
+ const { result } = await harness.executeOnce();
+
+ expect(result?.success).toBe(true);
+
+ harness.expectFile('dist/browser/index.html').toNotExist();
+ });
+
+ // TODO: This fails option validation when used in the CLI but not when used directly
+ xit('should fail build when true', async () => {
+ harness.useTarget('build', {
+ ...BASE_OPTIONS,
+ index: true,
+ });
+
+ const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false });
+
+ expect(result?.success).toBe(false);
+ harness.expectFile('dist/browser/index.html').toNotExist();
+ expect(logs).toContain(
+ jasmine.objectContaining({ message: jasmine.stringMatching('Schema validation failed') }),
+ );
+ });
+
+ it('should use the provided file path to generate the output file when a string path', async () => {
+ harness.useTarget('build', {
+ ...BASE_OPTIONS,
+ index: 'src/index.html',
+ });
+
+ await harness.writeFile(
+ 'src/index.html',
+ '
TEST_123',
+ );
+
+ const { result } = await harness.executeOnce();
+
+ expect(result?.success).toBe(true);
+ 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 () => {
+ harness.useTarget('build', {
+ ...BASE_OPTIONS,
+ index: 'src/not-here.html',
+ });
+
+ const { result } = await harness.executeOnce({ outputLogsOnFailure: false });
+
+ expect(result?.success).toBe(false);
+ harness.expectFile('dist/browser/index.html').toNotExist();
+ });
+
+ it('should generate initial preload link elements', async () => {
+ harness.useTarget('build', {
+ ...BASE_OPTIONS,
+ index: {
+ input: 'src/index.html',
+ preloadInitial: true,
+ },
+ });
+
+ // Setup an initial chunk usage for JS
+ await harness.writeFile('src/a.ts', 'console.log("TEST");');
+ await harness.writeFile('src/b.ts', 'import "./a";');
+ await harness.writeFile('src/main.ts', 'import "./a";\n(() => import("./b"))();');
+
+ const { result } = await harness.executeOnce();
+
+ expect(result?.success).toBe(true);
+ harness.expectFile('dist/browser/main.js').content.toContain('chunk-');
+ harness.expectFile('dist/browser/index.html').content.toContain('modulepreload');
+ harness.expectFile('dist/browser/index.html').content.toContain('chunk-');
+ });
+ });
+
+ describe('long form syntax', () => {
+ it('should use the provided input path to generate the output file when present', async () => {
+ harness.useTarget('build', {
+ ...BASE_OPTIONS,
+ index: {
+ input: 'src/index.html',
+ },
+ });
+
+ await harness.writeFile(
+ 'src/index.html',
+ 'TEST_123',
+ );
+
+ const { result } = await harness.executeOnce();
+
+ expect(result?.success).toBe(true);
+ harness.expectFile('dist/browser/index.html').content.toContain('TEST_123');
+ });
+
+ it('should use the provided output path to generate the output file when present', async () => {
+ harness.useTarget('build', {
+ ...BASE_OPTIONS,
+ index: {
+ input: 'src/index.html',
+ output: 'output.html',
+ },
+ });
+
+ await harness.writeFile(
+ 'src/index.html',
+ 'TEST_123',
+ );
+
+ const { result } = await harness.executeOnce();
+
+ expect(result?.success).toBe(true);
+ harness.expectFile('dist/browser/output.html').content.toContain('TEST_123');
+ });
+ });
+
+ it('should generate initial preload link elements when preloadInitial is true', async () => {
+ harness.useTarget('build', {
+ ...BASE_OPTIONS,
+ index: {
+ input: 'src/index.html',
+ preloadInitial: true,
+ },
+ });
+
+ // Setup an initial chunk usage for JS
+ await harness.writeFile('src/a.ts', 'console.log("TEST");');
+ await harness.writeFile('src/b.ts', 'import "./a";');
+ await harness.writeFile('src/main.ts', 'import "./a";\n(() => import("./b"))();');
+
+ const { result } = await harness.executeOnce();
+
+ expect(result?.success).toBe(true);
+ harness.expectFile('dist/browser/main.js').content.toContain('chunk-');
+ harness.expectFile('dist/browser/index.html').content.toContain('modulepreload');
+ harness.expectFile('dist/browser/index.html').content.toContain('chunk-');
+ });
+
+ it('should generate initial preload link elements when preloadInitial is undefined', async () => {
+ harness.useTarget('build', {
+ ...BASE_OPTIONS,
+ index: {
+ input: 'src/index.html',
+ preloadInitial: undefined,
+ },
+ });
+
+ // Setup an initial chunk usage for JS
+ await harness.writeFile('src/a.ts', 'console.log("TEST");');
+ await harness.writeFile('src/b.ts', 'import "./a";');
+ await harness.writeFile('src/main.ts', 'import "./a";\n(() => import("./b"))();');
+
+ const { result } = await harness.executeOnce();
+
+ expect(result?.success).toBe(true);
+ harness.expectFile('dist/browser/main.js').content.toContain('chunk-');
+ harness.expectFile('dist/browser/index.html').content.toContain('modulepreload');
+ harness.expectFile('dist/browser/index.html').content.toContain('chunk-');
+ });
+
+ it('should not generate initial preload link elements when preloadInitial is false', async () => {
+ harness.useTarget('build', {
+ ...BASE_OPTIONS,
+ index: {
+ input: 'src/index.html',
+ preloadInitial: false,
+ },
+ });
+
+ // Setup an initial chunk usage for JS
+ await harness.writeFile('src/a.ts', 'console.log("TEST");');
+ await harness.writeFile('src/b.ts', 'import "./a";');
+ await harness.writeFile('src/main.ts', 'import "./a";\n(() => import("./b"))();');
+
+ const { result } = await harness.executeOnce();
+
+ expect(result?.success).toBe(true);
+ harness.expectFile('dist/browser/main.js').content.toContain('chunk-');
+ harness.expectFile('dist/browser/index.html').content.not.toContain('modulepreload');
+ harness.expectFile('dist/browser/index.html').content.not.toContain('chunk-');
+ });
+ });
+});
diff --git a/packages/angular_devkit/build_angular/src/tools/esbuild/index-html-generator.ts b/packages/angular_devkit/build_angular/src/tools/esbuild/index-html-generator.ts
index 343ba2c6c7..2104a61ba2 100644
--- a/packages/angular_devkit/build_angular/src/tools/esbuild/index-html-generator.ts
+++ b/packages/angular_devkit/build_angular/src/tools/esbuild/index-html-generator.ts
@@ -38,7 +38,7 @@ export async function generateIndexHtml(
assert(indexHtmlOptions, 'indexHtmlOptions cannot be undefined.');
- if (!externalPackages) {
+ if (!externalPackages && indexHtmlOptions.preloadInitial) {
for (const [key, value] of initialFiles) {
if (value.entrypoint) {
// Entry points are already referenced in the HTML