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.'); + } +}