fix(upgrade): fix HMR for hybrid applications (#40045) · angular/angular@c4c7509

9 files changed

lines changed

Original file line numberDiff line numberDiff line change

@@ -35,7 +35,8 @@ export declare class UpgradeModule {

3535

ngZone: NgZone;

3636

constructor(

3737

injector: Injector,

38-

ngZone: NgZone);

38+

ngZone: NgZone,

39+

platformRef: PlatformRef);

3940

bootstrap(element: Element, modules?: string[], config?: any): void;

4041

}

4142
Original file line numberDiff line numberDiff line change

@@ -15,6 +15,7 @@ export const $INJECTOR = '$injector';

1515

export const $INTERVAL = '$interval';

1616

export const $PARSE = '$parse';

1717

export const $PROVIDE = '$provide';

18+

export const $ROOT_ELEMENT = '$rootElement';

1819

export const $ROOT_SCOPE = '$rootScope';

1920

export const $SCOPE = '$scope';

2021

export const $TEMPLATE_CACHE = '$templateCache';

Original file line numberDiff line numberDiff line change

@@ -8,8 +8,8 @@

88
99

import {Injector, Type} from '@angular/core';

1010
11-

import {element as angularElement, IInjectorService, INgModelController} from './angular1';

12-

import {DOWNGRADED_MODULE_COUNT_KEY, UPGRADE_APP_TYPE_KEY} from './constants';

11+

import {element as angularElement, IAugmentedJQuery, IInjectorService, INgModelController, IRootScopeService} from './angular1';

12+

import {$ROOT_ELEMENT, $ROOT_SCOPE, DOWNGRADED_MODULE_COUNT_KEY, UPGRADE_APP_TYPE_KEY} from './constants';

1313
1414

const DIRECTIVE_PREFIX_REGEXP = /^(?:x|data)[:\-_]/i;

1515

const DIRECTIVE_SPECIAL_CHARS_REGEXP = /[:\-_]+(.)/g;

@@ -48,6 +48,23 @@ export function controllerKey(name: string): string {

4848

return '$' + name + 'Controller';

4949

}

5050
51+

/**

52+

* Destroy an AngularJS app given the app `$injector`.

53+

*

54+

* NOTE: Destroying an app is not officially supported by AngularJS, but try to do our best by

55+

* destroying `$rootScope` and clean the jqLite/jQuery data on `$rootElement` and all

56+

* descendants.

57+

*

58+

* @param $injector The `$injector` of the AngularJS app to destroy.

59+

*/

60+

export function destroyApp($injector: IInjectorService): void {

61+

const $rootElement: IAugmentedJQuery = $injector.get($ROOT_ELEMENT);

62+

const $rootScope: IRootScopeService = $injector.get($ROOT_SCOPE);

63+
64+

$rootScope.$destroy();

65+

cleanData($rootElement[0]);

66+

}

67+
5168

export function directiveNormalize(name: string): string {

5269

return name.replace(DIRECTIVE_PREFIX_REGEXP, '')

5370

.replace(DIRECTIVE_SPECIAL_CHARS_REGEXP, (_, letter) => letter.toUpperCase());

Original file line numberDiff line numberDiff line change

@@ -13,7 +13,7 @@ import {bootstrap, element as angularElement, IAngularBootstrapConfig, IAugmente

1313

import {$$TESTABILITY, $COMPILE, $INJECTOR, $ROOT_SCOPE, COMPILER_KEY, INJECTOR_KEY, LAZY_MODULE_REF, NG_ZONE_KEY, UPGRADE_APP_TYPE_KEY} from '../../common/src/constants';

1414

import {downgradeComponent} from '../../common/src/downgrade_component';

1515

import {downgradeInjectable} from '../../common/src/downgrade_injectable';

16-

import {controllerKey, Deferred, LazyModuleRef, onError, UpgradeAppType} from '../../common/src/util';

16+

import {controllerKey, Deferred, destroyApp, LazyModuleRef, onError, UpgradeAppType} from '../../common/src/util';

1717
1818

import {UpgradeNg1ComponentAdapterBuilder} from './upgrade_ng1_adapter';

1919

@@ -619,6 +619,13 @@ export class UpgradeAdapter {

619619

rootScope.$on('$destroy', () => {

620620

subscription.unsubscribe();

621621

});

622+
623+

// Destroy the AngularJS app once the Angular `PlatformRef` is destroyed.

624+

// This does not happen in a typical SPA scenario, but it might be useful for

625+

// other use-cases where disposing of an Angular/AngularJS app is necessary

626+

// (such as Hot Module Replacement (HMR)).

627+

// See https://github.com/angular/angular/issues/39935.

628+

platformRef.onDestroy(() => destroyApp(ng1Injector));

622629

});

623630

})

624631

.catch((e) => this.ng2BootstrapDeferred.reject(e));

Original file line numberDiff line numberDiff line change

@@ -86,7 +86,7 @@ withEachNg1Version(() => {

8686

});

8787

}));

8888
89-

it('supports the compilerOptions argument', waitForAsync(() => {

89+

it('should support the compilerOptions argument', waitForAsync(() => {

9090

const platformRef = platformBrowserDynamic();

9191

spyOn(platformRef, 'bootstrapModule').and.callThrough();

9292

spyOn(platformRef, 'bootstrapModuleFactory').and.callThrough();

@@ -120,6 +120,64 @@ withEachNg1Version(() => {

120120

ref.dispose();

121121

});

122122

}));

123+
124+

it('should destroy the AngularJS app when `PlatformRef` is destroyed', waitForAsync(() => {

125+

const platformRef = platformBrowserDynamic();

126+

const adapter = new UpgradeAdapter(forwardRef(() => Ng2Module));

127+

const ng1Module = angular.module_('ng1', []);

128+
129+

@Component({selector: 'ng2', template: '<span>NG2</span>'})

130+

class Ng2Component {

131+

}

132+
133+

@NgModule({

134+

declarations: [Ng2Component],

135+

imports: [BrowserModule],

136+

})

137+

class Ng2Module {

138+

ngDoBootstrap() {}

139+

}

140+
141+

ng1Module.component('ng1', {template: '<ng2></ng2>'});

142+

ng1Module.directive('ng2', adapter.downgradeNg2Component(Ng2Component));

143+
144+

const element = html('<div><ng1></ng1></div>');

145+
146+

adapter.bootstrap(element, [ng1Module.name]).ready(ref => {

147+

const $rootScope: angular.IRootScopeService = ref.ng1Injector.get($ROOT_SCOPE);

148+

const rootScopeDestroySpy = spyOn($rootScope, '$destroy');

149+
150+

const appElem = angular.element(element);

151+

const ng1Elem = angular.element(element.querySelector('ng1') as Element);

152+

const ng2Elem = angular.element(element.querySelector('ng2') as Element);

153+

const ng2ChildElem = angular.element(element.querySelector('ng2 span') as Element);

154+
155+

// Attach data to all elements.

156+

appElem.data!('testData', 1);

157+

ng1Elem.data!('testData', 2);

158+

ng2Elem.data!('testData', 3);

159+

ng2ChildElem.data!('testData', 4);

160+
161+

// Verify data can be retrieved.

162+

expect(appElem.data!('testData')).toBe(1);

163+

expect(ng1Elem.data!('testData')).toBe(2);

164+

expect(ng2Elem.data!('testData')).toBe(3);

165+

expect(ng2ChildElem.data!('testData')).toBe(4);

166+
167+

expect(rootScopeDestroySpy).not.toHaveBeenCalled();

168+
169+

// Destroy `PlatformRef`.

170+

platformRef.destroy();

171+
172+

// Verify `$rootScope` has been destroyed and data has been cleaned up.

173+

expect(rootScopeDestroySpy).toHaveBeenCalled();

174+
175+

expect(appElem.data!('testData')).toBeUndefined();

176+

expect(ng1Elem.data!('testData')).toBeUndefined();

177+

expect(ng2Elem.data!('testData')).toBeUndefined();

178+

expect(ng2ChildElem.data!('testData')).toBeUndefined();

179+

});

180+

}));

123181

});

124182
125183

describe('bootstrap errors', () => {

Original file line numberDiff line numberDiff line change

@@ -6,12 +6,12 @@

66

* found in the LICENSE file at https://angular.io/license

77

*/

88
9-

import {Injector, NgModuleFactory, NgModuleRef, StaticProvider} from '@angular/core';

9+

import {Injector, NgModuleFactory, NgModuleRef, PlatformRef, StaticProvider} from '@angular/core';

1010

import {platformBrowser} from '@angular/platform-browser';

1111
1212

import {IInjectorService, IProvideService, module_ as angularModule} from '../../src/common/src/angular1';

1313

import {$INJECTOR, $PROVIDE, DOWNGRADED_MODULE_COUNT_KEY, INJECTOR_KEY, LAZY_MODULE_REF, UPGRADE_APP_TYPE_KEY, UPGRADE_MODULE_NAME} from '../../src/common/src/constants';

14-

import {getDowngradedModuleCount, isFunction, LazyModuleRef, UpgradeAppType} from '../../src/common/src/util';

14+

import {destroyApp, getDowngradedModuleCount, isFunction, LazyModuleRef, UpgradeAppType} from '../../src/common/src/util';

1515
1616

import {angular1Providers, setTempInjectorRef} from './angular1_providers';

1717

import {NgAdapterInjector} from './util';

@@ -167,6 +167,13 @@ export function downgradeModule<T>(moduleFactoryOrBootstrapFn: NgModuleFactory<T

167167

injector = result.injector = new NgAdapterInjector(ref.injector);

168168

injector.get($INJECTOR);

169169
170+

// Destroy the AngularJS app once the Angular `PlatformRef` is destroyed.

171+

// This does not happen in a typical SPA scenario, but it might be useful for

172+

// other use-cases where disposing of an Angular/AngularJS app is necessary

173+

// (such as Hot Module Replacement (HMR)).

174+

// See https://github.com/angular/angular/issues/39935.

175+

injector.get(PlatformRef).onDestroy(() => destroyApp($injector));

176+
170177

return injector;

171178

})

172179

};

Original file line numberDiff line numberDiff line change

@@ -6,11 +6,11 @@

66

* found in the LICENSE file at https://angular.io/license

77

*/

88
9-

import {Injector, isDevMode, NgModule, NgZone, Testability} from '@angular/core';

9+

import {Injector, isDevMode, NgModule, NgZone, PlatformRef, Testability} from '@angular/core';

1010
1111

import {bootstrap, element as angularElement, IInjectorService, IIntervalService, IProvideService, ITestabilityService, module_ as angularModule} from '../../src/common/src/angular1';

1212

import {$$TESTABILITY, $DELEGATE, $INJECTOR, $INTERVAL, $PROVIDE, INJECTOR_KEY, LAZY_MODULE_REF, UPGRADE_APP_TYPE_KEY, UPGRADE_MODULE_NAME} from '../../src/common/src/constants';

13-

import {controllerKey, LazyModuleRef, UpgradeAppType} from '../../src/common/src/util';

13+

import {controllerKey, destroyApp, LazyModuleRef, UpgradeAppType} from '../../src/common/src/util';

1414
1515

import {angular1Providers, setTempInjectorRef} from './angular1_providers';

1616

import {NgAdapterInjector} from './util';

@@ -155,7 +155,13 @@ export class UpgradeModule {

155155

/** The root `Injector` for the upgrade application. */

156156

injector: Injector,

157157

/** The bootstrap zone for the upgrade application */

158-

public ngZone: NgZone) {

158+

public ngZone: NgZone,

159+

/**

160+

* The owning `NgModuleRef`s `PlatformRef` instance.

161+

* This is used to tie the lifecycle of the bootstrapped AngularJS apps to that of the Angular

162+

* `PlatformRef`.

163+

*/

164+

private platformRef: PlatformRef) {

159165

this.injector = new NgAdapterInjector(injector);

160166

}

161167

@@ -242,6 +248,7 @@ export class UpgradeModule {

242248

$INJECTOR,

243249

($injector: IInjectorService) => {

244250

this.$injector = $injector;

251+

const $rootScope = $injector.get('$rootScope');

245252
246253

// Initialize the ng1 $injector provider

247254

setTempInjectorRef($injector);

@@ -250,10 +257,16 @@ export class UpgradeModule {

250257

// Put the injector on the DOM, so that it can be "required"

251258

angularElement(element).data!(controllerKey(INJECTOR_KEY), this.injector);

252259
260+

// Destroy the AngularJS app once the Angular `PlatformRef` is destroyed.

261+

// This does not happen in a typical SPA scenario, but it might be useful for

262+

// other use-cases where disposing of an Angular/AngularJS app is necessary

263+

// (such as Hot Module Replacement (HMR)).

264+

// See https://github.com/angular/angular/issues/39935.

265+

this.platformRef.onDestroy(() => destroyApp($injector));

266+
253267

// Wire up the ng1 rootScope to run a digest cycle whenever the zone settles

254268

// We need to do this in the next tick so that we don't prevent the bootup stabilizing

255269

setTimeout(() => {

256-

const $rootScope = $injector.get('$rootScope');

257270

const subscription = this.ngZone.onMicrotaskEmpty.subscribe(() => {

258271

if ($rootScope.$$phase) {

259272

if (isDevMode()) {

Original file line numberDiff line numberDiff line change

@@ -13,6 +13,7 @@ import {platformBrowserDynamic} from '@angular/platform-browser-dynamic';

1313

import {downgradeComponent, UpgradeComponent, UpgradeModule} from '@angular/upgrade/static';

1414
1515

import * as angular from '../../../src/common/src/angular1';

16+

import {$ROOT_SCOPE} from '../../../src/common/src/constants';

1617

import {html, multiTrim, withEachNg1Version} from '../../../src/common/test/helpers/common_test_helpers';

1718
1819

import {$apply, bootstrap} from './static_test_helpers';

@@ -648,6 +649,66 @@ withEachNg1Version(() => {

648649

});

649650

}));

650651
652+

it('should destroy the AngularJS app when `PlatformRef` is destroyed', waitForAsync(() => {

653+

@Component({selector: 'ng2', template: '<span>NG2</span>'})

654+

class Ng2Component {

655+

}

656+
657+

@NgModule({

658+

declarations: [Ng2Component],

659+

entryComponents: [Ng2Component],

660+

imports: [BrowserModule, UpgradeModule],

661+

})

662+

class Ng2Module {

663+

ngDoBootstrap() {}

664+

}

665+
666+

const ng1Module = angular.module_('ng1', [])

667+

.component('ng1', {template: '<ng2></ng2>'})

668+

.directive('ng2', downgradeComponent({component: Ng2Component}));

669+
670+

const element = html('<div><ng1></ng1></div>');

671+

const platformRef = platformBrowserDynamic();

672+
673+

platformRef.bootstrapModule(Ng2Module).then(ref => {

674+

const upgrade = ref.injector.get(UpgradeModule);

675+

upgrade.bootstrap(element, [ng1Module.name]);

676+
677+

const $rootScope: angular.IRootScopeService = upgrade.$injector.get($ROOT_SCOPE);

678+

const rootScopeDestroySpy = spyOn($rootScope, '$destroy');

679+
680+

const appElem = angular.element(element);

681+

const ng1Elem = angular.element(element.querySelector('ng1') as Element);

682+

const ng2Elem = angular.element(element.querySelector('ng2') as Element);

683+

const ng2ChildElem = angular.element(element.querySelector('ng2 span') as Element);

684+
685+

// Attach data to all elements.

686+

appElem.data!('testData', 1);

687+

ng1Elem.data!('testData', 2);

688+

ng2Elem.data!('testData', 3);

689+

ng2ChildElem.data!('testData', 4);

690+
691+

// Verify data can be retrieved.

692+

expect(appElem.data!('testData')).toBe(1);

693+

expect(ng1Elem.data!('testData')).toBe(2);

694+

expect(ng2Elem.data!('testData')).toBe(3);

695+

expect(ng2ChildElem.data!('testData')).toBe(4);

696+
697+

expect(rootScopeDestroySpy).not.toHaveBeenCalled();

698+
699+

// Destroy `PlatformRef`.

700+

platformRef.destroy();

701+
702+

// Verify `$rootScope` has been destroyed and data has been cleaned up.

703+

expect(rootScopeDestroySpy).toHaveBeenCalled();

704+
705+

expect(appElem.data!('testData')).toBeUndefined();

706+

expect(ng1Elem.data!('testData')).toBeUndefined();

707+

expect(ng2Elem.data!('testData')).toBeUndefined();

708+

expect(ng2ChildElem.data!('testData')).toBeUndefined();

709+

});

710+

}));

711+
651712

it('should work when compiled outside the dom (by fallback to the root ng2.injector)',

652713

waitForAsync(() => {

653714

@Component({selector: 'ng2', template: 'test'})

Original file line numberDiff line numberDiff line change

@@ -1353,6 +1353,68 @@ withEachNg1Version(() => {

13531353

setTimeout(() => expect($injectorFromNg2).toBe($injectorFromNg1));

13541354

}));

13551355
1356+

it('should destroy the AngularJS app when `PlatformRef` is destroyed', waitForAsync(() => {

1357+

@Component({selector: 'ng2', template: '<span>NG2</span>'})

1358+

class Ng2Component {

1359+

}

1360+
1361+

@NgModule({

1362+

declarations: [Ng2Component],

1363+

entryComponents: [Ng2Component],

1364+

imports: [BrowserModule],

1365+

})

1366+

class Ng2Module {

1367+

ngDoBootstrap() {}

1368+

}

1369+
1370+

const bootstrapFn = (extraProviders: StaticProvider[]) =>

1371+

platformBrowserDynamic(extraProviders).bootstrapModule(Ng2Module);

1372+

const lazyModuleName = downgradeModule<Ng2Module>(bootstrapFn);

1373+

const ng1Module =

1374+

angular.module_('ng1', [lazyModuleName])

1375+

.component('ng1', {template: '<ng2></ng2>'})

1376+

.directive(

1377+

'ng2', downgradeComponent({component: Ng2Component, propagateDigest}));

1378+
1379+

const element = html('<div><ng1></ng1></div>');

1380+

const $injector = angular.bootstrap(element, [ng1Module.name]);

1381+
1382+

setTimeout(() => { // Wait for the module to be bootstrapped.

1383+

const $rootScope: angular.IRootScopeService = $injector.get($ROOT_SCOPE);

1384+

const rootScopeDestroySpy = spyOn($rootScope, '$destroy');

1385+
1386+

const appElem = angular.element(element);

1387+

const ng1Elem = angular.element(element.querySelector('ng1') as Element);

1388+

const ng2Elem = angular.element(element.querySelector('ng2') as Element);

1389+

const ng2ChildElem = angular.element(element.querySelector('ng2 span') as Element);

1390+
1391+

// Attach data to all elements.

1392+

appElem.data!('testData', 1);

1393+

ng1Elem.data!('testData', 2);

1394+

ng2Elem.data!('testData', 3);

1395+

ng2ChildElem.data!('testData', 4);

1396+
1397+

// Verify data can be retrieved.

1398+

expect(appElem.data!('testData')).toBe(1);

1399+

expect(ng1Elem.data!('testData')).toBe(2);

1400+

expect(ng2Elem.data!('testData')).toBe(3);

1401+

expect(ng2ChildElem.data!('testData')).toBe(4);

1402+
1403+

expect(rootScopeDestroySpy).not.toHaveBeenCalled();

1404+
1405+

// Destroy `PlatformRef`.

1406+

getPlatform()?.destroy();

1407+
1408+

// Verify `$rootScope` has been destroyed and data has been cleaned up.

1409+

expect(rootScopeDestroySpy).toHaveBeenCalled();

1410+
1411+

expect(appElem.data!('testData')).toBeUndefined();

1412+

expect(ng1Elem.data!('testData')).toBeUndefined();

1413+

expect(ng2Elem.data!('testData')).toBeUndefined();

1414+

expect(ng2ChildElem.data!('testData')).toBeUndefined();

1415+

});

1416+

}));

1417+
13561418

describe('(common error)', () => {

13571419

let Ng2CompA: Type<any>;

13581420

let Ng2CompB: Type<any>;