From 6b636b3ee1a1a1adc7b51cf373b32b60d06f8bb7 Mon Sep 17 00:00:00 2001 From: Alan Agius Date: Fri, 25 Sep 2020 10:35:11 +0200 Subject: [PATCH] feat(@angular-devkit/build-angular): out of the box hot module replacement (HMR) With this change we configure HMR internally and therefore users which want to use basic HMR functionality will not longer be required to change their application code. This is important because previously a lot of users missed out on HMR or reported a broken behaviour. This also gives novice users a better chance to appreciate HMR and Angular because of the zero effort required to use HMR. Closes #17324 --- .../build_angular/src/dev-server/index.ts | 24 +- .../build_angular/src/webpack/hmr.js | 212 ++++++++++++++++++ 2 files changed, 220 insertions(+), 16 deletions(-) create mode 100644 packages/angular_devkit/build_angular/src/webpack/hmr.js diff --git a/packages/angular_devkit/build_angular/src/dev-server/index.ts b/packages/angular_devkit/build_angular/src/dev-server/index.ts index 91adbd0e6bf8..55a140c95383 100644 --- a/packages/angular_devkit/build_angular/src/dev-server/index.ts +++ b/packages/angular_devkit/build_angular/src/dev-server/index.ts @@ -558,22 +558,14 @@ function _addLiveReload( const entryPoints = [`${webpackDevServerPath}?${url.format(clientAddress)}${sockjsPath}`]; if (options.hmr) { - const webpackHmrLink = 'https://webpack.js.org/guides/hot-module-replacement'; - logger.warn(tags.oneLine`NOTICE: Hot Module Replacement (HMR) is enabled for the dev server.`); - - const showWarning = options.hmrWarning; - if (showWarning) { - logger.info(tags.stripIndents` - The project will still live reload when HMR is enabled, but to take full advantage of HMR - additional application code which is not included by default in an Angular CLI project is required. - - See ${webpackHmrLink} for information on working with HMR for Webpack.`); - logger.warn( - tags.oneLine`To disable this warning use "hmrWarning: false" under "serve" - options in "angular.json".`, - ); - } - entryPoints.push('webpack/hot/dev-server'); + logger.warn(tags.stripIndents`NOTICE: Hot Module Replacement (HMR) is enabled for the dev server. + See https://webpack.js.org/guides/hot-module-replacement for information on working with HMR for Webpack.`); + + entryPoints.push( + 'webpack/hot/dev-server', + path.join(__dirname, '../webpack/hmr.js'), + ); + if (browserOptions.styles?.length) { // When HMR is enabled we need to add the css paths as part of the entrypoints // because otherwise no JS bundle will contain the HMR accept code. diff --git a/packages/angular_devkit/build_angular/src/webpack/hmr.js b/packages/angular_devkit/build_angular/src/webpack/hmr.js new file mode 100644 index 000000000000..ad7dcc35125d --- /dev/null +++ b/packages/angular_devkit/build_angular/src/webpack/hmr.js @@ -0,0 +1,212 @@ +/** + * @license + * Copyright Google Inc. 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 + */ + +// TODO: change the file to TypeScript and build soley using Bazel. + +import { ApplicationRef, PlatformRef, ɵresetCompiledComponents } from '@angular/core'; +import { filter, take } from 'rxjs/operators'; + +if (module['hot']) { + module['hot'].accept(); + module['hot'].dispose(() => { + if (typeof ng === 'undefined') { + console.warn(`[NG HMR] Cannot find global 'ng'. Likely this is caused because scripts optimization is enabled.`); + + return; + } + + if (!ng.getInjector) { + // View Engine + return; + } + + // Reset JIT compiled components cache + ɵresetCompiledComponents(); + const appRoot = getAppRoot(); + if (!appRoot) { + return; + } + + const appRef = getApplicationRef(appRoot); + if (!appRef) { + return; + } + + const oldInputs = document.querySelectorAll('input, textarea'); + const oldOptions = document.querySelectorAll('option'); + + // Create new application + appRef.components + .forEach(cp => { + const element = cp.location.nativeElement; + const parentNode = element.parentNode; + parentNode.insertBefore( + document.createElement(element.tagName), + element, + ); + + parentNode.removeChild(element); + }); + + // Destroy old application, injectors, { + observer.disconnect(); + + const newAppRoot = getAppRoot(); + if (!newAppRoot) { + return; + } + + const newAppRef = getApplicationRef(newAppRoot); + if (!newAppRef) { + return; + } + + // Wait until the application isStable to restore the form values + newAppRef.isStable + .pipe( + filter(isStable => !!isStable), + take(1), + ) + .subscribe(() => restoreFormValues(oldInputs, oldOptions)); + }) + .observe(bodyElement, { + attributes: true, + subtree: true, + attributeFilter: ['ng-version'], + }); + }); +} + +function getAppRoot() { + const appRoot = document.querySelector('[ng-version]'); + if (!appRoot) { + console.warn('[NG HMR] Cannot find the application root component.'); + + return undefined; + } + + return appRoot; +} + +function getToken(appRoot, token) { + return typeof ng === 'object' && ng.getInjector(appRoot).get(token) || undefined; +} + +function getApplicationRef(appRoot) { + const appRef = getToken(appRoot, ApplicationRef); + if (!appRef) { + console.warn(`[NG HMR] Cannot get 'ApplicationRef'.`); + + return undefined; + } + + return appRef; +} + +function getPlatformRef(appRoot) { + const platformRef = getToken(appRoot, PlatformRef); + if (!platformRef) { + console.warn(`[NG HMR] Cannot get 'PlatformRef'.`); + + return undefined; + } + + return platformRef; +} + +function dispatchEvents(element) { + element.dispatchEvent(new Event('input', { + bubbles: true, + cancelable: true, + })); + + element.blur(); + + element.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter' })); +} + +function restoreFormValues(oldInputs, oldOptions) { + // Restore input + const newInputs = document.querySelectorAll('input, textarea'); + if (newInputs.length && newInputs.length === oldInputs.length) { + console.log('[NG HMR] Restoring input/textarea values.'); + for (let index = 0; index < newInputs.length; index++) { + const newElement = newInputs[index]; + const oldElement = oldInputs[index]; + + switch (oldElement.type) { + case 'button': + case 'image': + case 'submit': + case 'reset': + // These types don't need any value change. + continue; + case 'radio': + case 'checkbox': + newElement.checked = oldElement.checked; + break; + case 'color': + case 'date': + case 'datetime-local': + case 'email': + case 'file': + case 'hidden': + case 'image': + case 'month': + case 'number': + case 'password': + case 'radio': + case 'range': + case 'search': + case 'tel': + case 'text': + case 'textarea': + case 'time': + case 'url': + case 'week': + newElement.value = oldElement.value; + break; + default: + console.warn('[NG HMR] Unknown input type ' + oldElement.type + '.'); + continue; + } + + dispatchEvents(newElement); + } + } else { + console.warn('[NG HMR] Cannot restore input/textarea values.'); + } + + // Restore option + const newOptions = document.querySelectorAll('option'); + if (newOptions.length && newOptions.length === oldOptions.length) { + console.log('[NG HMR] Restoring selected options.'); + for (let index = 0; index < newOptions.length; index++) { + const newElement = newOptions[index]; + newElement.selected = oldOptions[index].selected; + + dispatchEvents(newElement); + } + } else { + console.warn('[NG HMR] Cannot restore selected options.'); + } +}