From b22089dadd25e15f2c824d3136dc244c695737af Mon Sep 17 00:00:00 2001 From: martinfrancois Date: Fri, 8 Jul 2022 20:01:26 +0200 Subject: [PATCH] fix(@angular-devkit/build-angular): generate different content hashes for scripts which are changed during the optimization phase Instead of generating the content hash based on the content of scripts BEFORE the optimization phase, the content hash is generated AFTER the optimization phase. Prevents caching issues where browsers block execution of scripts due to the integrity hash not matching with the cached script in case of a script being optimized differently than in a previous build, where it would previously result in the same content hash. Fixes #22906 --- .../webpack/plugins/scripts-webpack-plugin.ts | 40 ++++++++++++----- .../e2e/tests/build/scripts-output-hashing.ts | 45 +++++++++++++++++++ 2 files changed, 74 insertions(+), 11 deletions(-) create mode 100644 tests/legacy-cli/e2e/tests/build/scripts-output-hashing.ts diff --git a/packages/angular_devkit/build_angular/src/webpack/plugins/scripts-webpack-plugin.ts b/packages/angular_devkit/build_angular/src/webpack/plugins/scripts-webpack-plugin.ts index 31595dfb93b3..b62f045fe234 100644 --- a/packages/angular_devkit/build_angular/src/webpack/plugins/scripts-webpack-plugin.ts +++ b/packages/angular_devkit/build_angular/src/webpack/plugins/scripts-webpack-plugin.ts @@ -12,6 +12,11 @@ import { Chunk, Compilation, Compiler, sources as webpackSources } from 'webpack const Entrypoint = require('webpack/lib/Entrypoint'); +/** + * The name of the plugin provided to Webpack when tapping Webpack compiler hooks. + */ +const PLUGIN_NAME = 'scripts-webpack-plugin'; + export interface ScriptsWebpackPluginOptions { name: string; sourceMap?: boolean; @@ -97,8 +102,8 @@ export class ScriptsWebpackPlugin { .filter((script) => !!script) .map((script) => path.resolve(this.options.basePath || '', script)); - compiler.hooks.thisCompilation.tap('scripts-webpack-plugin', (compilation) => { - compilation.hooks.additionalAssets.tapPromise('scripts-webpack-plugin', async () => { + compiler.hooks.thisCompilation.tap(PLUGIN_NAME, (compilation) => { + compilation.hooks.additionalAssets.tapPromise(PLUGIN_NAME, async () => { if (await this.shouldSkip(compilation, scripts)) { if (this._cachedOutput) { this._insertOutput(compilation, this._cachedOutput, true); @@ -149,19 +154,32 @@ export class ScriptsWebpackPlugin { }); const combinedSource = new webpackSources.CachedSource(concatSource); - const filename = interpolateName( - { resourcePath: 'scripts.js' }, - this.options.filename as string, - { - content: combinedSource.source(), - }, - ); - - const output = { filename, source: combinedSource }; + + const output = { filename: this.options.filename, source: combinedSource }; this._insertOutput(compilation, output); this._cachedOutput = output; addDependencies(compilation, scripts); }); + compilation.hooks.processAssets.tapPromise( + { + name: PLUGIN_NAME, + stage: compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_DEV_TOOLING, + }, + async () => { + const assetName = this.options.filename; + const asset = compilation.getAsset(assetName); + if (asset) { + const interpolatedFilename = interpolateName( + { resourcePath: 'scripts.js' }, + assetName, + { content: asset.source.source() }, + ); + if (assetName !== interpolatedFilename) { + compilation.renameAsset(assetName, interpolatedFilename); + } + } + }, + ); }); } } diff --git a/tests/legacy-cli/e2e/tests/build/scripts-output-hashing.ts b/tests/legacy-cli/e2e/tests/build/scripts-output-hashing.ts new file mode 100644 index 000000000000..430b7a8478ac --- /dev/null +++ b/tests/legacy-cli/e2e/tests/build/scripts-output-hashing.ts @@ -0,0 +1,45 @@ +import { expectFileMatchToExist, expectFileToMatch, writeMultipleFiles } from '../../utils/fs'; +import { ng } from '../../utils/process'; +import { updateJsonFile, updateTsConfig } from '../../utils/project'; + +function getScriptsFilename(): Promise { + return expectFileMatchToExist('dist/test-project/', /external-module\.[0-9a-f]{16}\.js/); +} + +export default async function () { + // verify content hash is based on code after optimizations + await writeMultipleFiles({ + 'src/script.js': 'try { console.log(); } catch {}', + }); + await updateJsonFile('angular.json', (configJson) => { + const build = configJson.projects['test-project'].architect.build; + build.options['scripts'] = [ + { + input: 'src/script.js', + inject: true, + bundleName: 'external-module', + }, + ]; + build.configurations['production'].outputHashing = 'all'; + configJson['cli'] = { cache: { enabled: 'false' } }; + }); + await updateTsConfig((json) => { + json['compilerOptions']['target'] = 'es2017'; + json['compilerOptions']['module'] = 'es2020'; + }); + await ng('build', '--configuration=production'); + const filenameBuild1 = await getScriptsFilename(); + await expectFileToMatch(`dist/test-project/${filenameBuild1}`, 'try{console.log()}catch(c){}'); + + await updateTsConfig((json) => { + json['compilerOptions']['target'] = 'es2019'; + }); + await ng('build', '--configuration=production'); + const filenameBuild2 = await getScriptsFilename(); + await expectFileToMatch(`dist/test-project/${filenameBuild2}`, 'try{console.log()}catch{}'); + if (filenameBuild1 === filenameBuild2) { + throw new Error( + 'Contents of the built file changed between builds, but the content hash stayed the same!', + ); + } +}