Skip to content

Commit 97fec92

Browse files
authored
fix(router-outlet): attach entering view before first change detection (#18821)
1 parent 26e6d6f commit 97fec92

File tree

10 files changed

+112
-63
lines changed

10 files changed

+112
-63
lines changed

angular/src/directives/navigation/ion-router-outlet.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,6 @@ export class IonRouterOutlet implements OnDestroy, OnInit {
205205
// Calling `markForCheck` to make sure we will run the change detection when the
206206
// `RouterOutlet` is inside a `ChangeDetectionStrategy.OnPush` component.
207207
enteringView = this.stackCtrl.createView(this.activated, activatedRoute);
208-
enteringView.ref.changeDetectorRef.detectChanges();
209208

210209
// Store references to the proxy by component
211210
this.proxyMap.set(cmpRef.instance, activatedRouteProxy);

angular/src/directives/navigation/stack-controller.ts

Lines changed: 28 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export class StackController {
3131
createView(ref: ComponentRef<any>, activatedRoute: ActivatedRoute): RouteView {
3232
const url = getUrl(this.router, activatedRoute);
3333
const element = (ref && ref.location && ref.location.nativeElement) as HTMLElement;
34-
const unlistenEvents = bindLifecycleEvents(ref.instance, element);
34+
const unlistenEvents = bindLifecycleEvents(this.zone, ref.instance, element);
3535
return {
3636
id: this.nextId++,
3737
stackId: computeStackId(this.tabsPrefix, url),
@@ -90,16 +90,28 @@ export class StackController {
9090
}
9191
}
9292

93+
const reused = this.views.includes(enteringView);
9394
const views = this.insertView(enteringView, direction);
94-
return this.wait(() => {
95-
return this.transition(enteringView, leavingView, animation, this.canGoBack(1), false)
96-
.then(() => cleanupAsync(enteringView, views, viewsSnapshot, this.location))
97-
.then(() => ({
98-
enteringView,
99-
direction,
100-
animation,
101-
tabSwitch
102-
}));
95+
96+
// Trigger change detection before transition starts
97+
// This will call ngOnInit() the first time too, just after the view
98+
// was attached to the dom, but BEFORE the transition starts
99+
if (!reused) {
100+
enteringView.ref.changeDetectorRef.detectChanges();
101+
}
102+
103+
// Wait until previous transitions finish
104+
return this.zone.runOutsideAngular(() => {
105+
return this.wait(() => {
106+
return this.transition(enteringView, leavingView, animation, this.canGoBack(1), false)
107+
.then(() => cleanupAsync(enteringView, views, viewsSnapshot, this.location))
108+
.then(() => ({
109+
enteringView,
110+
direction,
111+
animation,
112+
tabSwitch
113+
}));
114+
});
103115
});
104116
}
105117

@@ -199,11 +211,11 @@ export class StackController {
199211
if (enteringView) {
200212
enteringView.ref.changeDetectorRef.reattach();
201213
}
202-
// TODO: disconnect leaving page from change detection to
214+
// disconnect leaving page from change detection to
203215
// reduce jank during the page transition
204-
// if (leavingView) {
205-
// leavingView.ref.changeDetectorRef.detach();
206-
// }
216+
if (leavingView) {
217+
leavingView.ref.changeDetectorRef.detach();
218+
}
207219
const enteringEl = enteringView ? enteringView.element : undefined;
208220
const leavingEl = leavingView ? leavingView.element : undefined;
209221
const containerEl = this.containerEl;
@@ -213,13 +225,13 @@ export class StackController {
213225
containerEl.appendChild(enteringEl);
214226
}
215227

216-
return this.zone.runOutsideAngular(() => containerEl.commit(enteringEl, leavingEl, {
228+
return containerEl.commit(enteringEl, leavingEl, {
217229
deepWait: true,
218230
duration: direction === undefined ? 0 : undefined,
219231
direction,
220232
showGoBack,
221233
progressAnimation
222-
}));
234+
});
223235
}
224236
return Promise.resolve(false);
225237
}

angular/src/providers/angular-delegate.ts

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,10 @@ export class AngularFrameworkDelegate implements FrameworkDelegate {
3434
) {}
3535

3636
attachViewToDom(container: any, component: any, params?: any, cssClasses?: string[]): Promise<any> {
37-
return new Promise(resolve => {
38-
this.zone.run(() => {
37+
return this.zone.run(() => {
38+
return new Promise(resolve => {
3939
const el = attachView(
40-
this.resolver, this.injector, this.location, this.appRef,
40+
this.zone, this.resolver, this.injector, this.location, this.appRef,
4141
this.elRefMap, this.elEventsMap,
4242
container, component, params, cssClasses
4343
);
@@ -47,8 +47,8 @@ export class AngularFrameworkDelegate implements FrameworkDelegate {
4747
}
4848

4949
removeViewFromDom(_container: any, component: any): Promise<void> {
50-
return new Promise(resolve => {
51-
this.zone.run(() => {
50+
return this.zone.run(() => {
51+
return new Promise(resolve => {
5252
const componentRef = this.elRefMap.get(component);
5353
if (componentRef) {
5454
componentRef.destroy();
@@ -66,6 +66,7 @@ export class AngularFrameworkDelegate implements FrameworkDelegate {
6666
}
6767

6868
export function attachView(
69+
zone: NgZone,
6970
resolver: ComponentFactoryResolver,
7071
injector: Injector,
7172
location: ViewContainerRef | undefined,
@@ -93,7 +94,7 @@ export function attachView(
9394
hostElement.classList.add(clazz);
9495
}
9596
}
96-
const unbindEvents = bindLifecycleEvents(instance, hostElement);
97+
const unbindEvents = bindLifecycleEvents(zone, instance, hostElement);
9798
container.appendChild(hostElement);
9899

99100
if (!location) {
@@ -113,15 +114,17 @@ const LIFECYCLES = [
113114
LIFECYCLE_WILL_UNLOAD
114115
];
115116

116-
export function bindLifecycleEvents(instance: any, element: HTMLElement) {
117-
const unregisters = LIFECYCLES
118-
.filter(eventName => typeof instance[eventName] === 'function')
119-
.map(eventName => {
120-
const handler = (ev: any) => instance[eventName](ev.detail);
121-
element.addEventListener(eventName, handler);
122-
return () => element.removeEventListener(eventName, handler);
123-
});
124-
return () => unregisters.forEach(fn => fn());
117+
export function bindLifecycleEvents(zone: NgZone, instance: any, element: HTMLElement) {
118+
return zone.run(() => {
119+
const unregisters = LIFECYCLES
120+
.filter(eventName => typeof instance[eventName] === 'function')
121+
.map(eventName => {
122+
const handler = (ev: any) => instance[eventName](ev.detail);
123+
element.addEventListener(eventName, handler);
124+
return () => element.removeEventListener(eventName, handler);
125+
});
126+
return () => unregisters.forEach(fn => fn());
127+
});
125128
}
126129

127130
const NavParamsToken = new InjectionToken<any>('NavParamsToken');

angular/test/test-app/e2e/src/router-link.e2e-spec.ts

Lines changed: 29 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { browser, element, by, protractor } from 'protractor';
2-
import { waitTime, testStack, testLifeCycle, handleErrorMessages } from './utils';
2+
import { waitTime, testStack, testLifeCycle, handleErrorMessages, getText } from './utils';
33

44
const EC = protractor.ExpectedConditions;
55

@@ -23,12 +23,13 @@ describe('router-link params and fragments', () => {
2323

2424
it('should return to a page with preserved query param and fragment', async () => {
2525
await browser.get('/router-link?ionic:_testing=true');
26+
await waitTime(30);
2627
await element(by.css('#queryParamsFragment')).click();
27-
await waitTime(200);
28+
await waitTime(400);
2829
await element(by.css('#goToPage3')).click();
2930

3031
browser.wait(EC.urlContains('router-link-page3'), 5000);
31-
await waitTime(200);
32+
await waitTime(400);
3233

3334
await element(by.css('#goBackFromPage3')).click();
3435

@@ -38,6 +39,7 @@ describe('router-link params and fragments', () => {
3839

3940
it('should preserve query param and fragment with defaultHref string', async () => {
4041
await browser.get('/router-link-page3?ionic:_testing=true');
42+
await waitTime(30);
4143

4244
await element(by.css('#goBackFromPage3')).click();
4345

@@ -71,18 +73,6 @@ describe('router-link', () => {
7173
it('should go forward with ion-button[routerLink]', async () => {
7274
await element(by.css('#routerLink')).click();
7375
await testForward();
74-
75-
// test go back
76-
await element(by.css('ion-back-button')).click();
77-
await waitTime(500);
78-
79-
await testStack('ion-router-outlet', ['app-router-link']);
80-
await testLifeCycle('app-router-link', {
81-
ionViewWillEnter: 2,
82-
ionViewDidEnter: 2,
83-
ionViewWillLeave: 1,
84-
ionViewDidLeave: 1,
85-
});
8676
});
8777

8878
it('should go forward with a[routerLink]', async () => {
@@ -140,18 +130,23 @@ describe('router-link', () => {
140130
async function testForward() {
141131
await waitTime(2500);
142132
await testStack('ion-router-outlet', ['app-router-link', 'app-router-link-page']);
143-
await testLifeCycle('app-router-link', {
144-
ionViewWillEnter: 1,
145-
ionViewDidEnter: 1,
146-
ionViewWillLeave: 1,
147-
ionViewDidLeave: 1,
148-
});
149133
await testLifeCycle('app-router-link-page', {
150134
ionViewWillEnter: 1,
151135
ionViewDidEnter: 1,
152136
ionViewWillLeave: 0,
153137
ionViewDidLeave: 0,
154138
});
139+
expect(await getText(`app-router-link-page #canGoBack`)).toEqual('true');
140+
141+
await browser.navigate().back();
142+
await waitTime(100);
143+
await testStack('ion-router-outlet', ['app-router-link']);
144+
await testLifeCycle('app-router-link', {
145+
ionViewWillEnter: 2,
146+
ionViewDidEnter: 2,
147+
ionViewWillLeave: 1,
148+
ionViewDidLeave: 1,
149+
});
155150
}
156151

157152
async function testRoot() {
@@ -163,6 +158,8 @@ async function testRoot() {
163158
ionViewWillLeave: 0,
164159
ionViewDidLeave: 0,
165160
});
161+
expect(await getText(`app-router-link-page #canGoBack`)).toEqual('false');
162+
166163
await browser.navigate().back();
167164
await waitTime(100);
168165
await testStack('ion-router-outlet', ['app-router-link']);
@@ -183,4 +180,15 @@ async function testBack() {
183180
ionViewWillLeave: 0,
184181
ionViewDidLeave: 0,
185182
});
183+
expect(await getText(`app-router-link-page #canGoBack`)).toEqual('false');
184+
185+
await browser.navigate().back();
186+
await waitTime(100);
187+
await testStack('ion-router-outlet', ['app-router-link']);
188+
await testLifeCycle('app-router-link', {
189+
ionViewWillEnter: 1,
190+
ionViewDidEnter: 1,
191+
ionViewWillLeave: 0,
192+
ionViewDidLeave: 0,
193+
});
186194
}

angular/test/test-app/e2e/src/utils.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -48,11 +48,19 @@ export function handleErrorMessages() {
4848

4949
export async function testLifeCycle(selector: string, expected: LifeCycleCount) {
5050
await waitTime(50);
51-
expect(await getText(`${selector} #ngOnInit`)).toEqual('1');
52-
expect(await getText(`${selector} #ionViewWillEnter`)).toEqual(expected.ionViewWillEnter.toString());
53-
expect(await getText(`${selector} #ionViewDidEnter`)).toEqual(expected.ionViewDidEnter.toString());
54-
expect(await getText(`${selector} #ionViewWillLeave`)).toEqual(expected.ionViewWillLeave.toString());
55-
expect(await getText(`${selector} #ionViewDidLeave`)).toEqual(expected.ionViewDidLeave.toString());
51+
const results = await Promise.all([
52+
getText(`${selector} #ngOnInit`),
53+
getText(`${selector} #ionViewWillEnter`),
54+
getText(`${selector} #ionViewDidEnter`),
55+
getText(`${selector} #ionViewWillLeave`),
56+
getText(`${selector} #ionViewDidLeave`),
57+
]);
58+
59+
expect(results[0]).toEqual('1');
60+
expect(results[1]).toEqual(expected.ionViewWillEnter.toString());
61+
expect(results[2]).toEqual(expected.ionViewDidEnter.toString());
62+
expect(results[3]).toEqual(expected.ionViewWillLeave.toString());
63+
expect(results[4]).toEqual(expected.ionViewDidLeave.toString());
5664
}
5765

5866
export async function testStack(selector: string, expected: string[]) {
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
<ion-header>
22
<ion-toolbar>
33
<ion-title>
4-
Modal test
4+
Alert test
55
</ion-title>
66
</ion-toolbar>
77
</ion-header>
88
<ion-content padding>
9-
<ion-button (click)="openAlert()" id="action-button">Open Alert</ion-button>
9+
<p>Change Detections: <span id="counter">{{counter()}}</span></p>
1010
</ion-content>

angular/test/test-app/src/app/alert/alert.component.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,17 @@ import { NavComponent } from '../nav/nav.component';
88
})
99
export class AlertComponent {
1010

11+
changes = 0;
12+
1113
constructor(
1214
private alertCtrl: AlertController
1315
) { }
1416

17+
counter() {
18+
this.changes++;
19+
return Math.floor(this.changes / 2);
20+
}
21+
1522
async openAlert() {
1623
const alert = await this.alertCtrl.create({
1724
header: 'Hello',

angular/test/test-app/src/app/router-link-page/router-link-page.component.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
<ion-content padding>
1111
<p>ngOnInit: <span id="ngOnInit">{{onInit}}</span></p>
12+
<p>canGoBack: <span id="canGoBack">{{canGoBack}}</span></p>
1213
<p>ionViewWillEnter: <span id="ionViewWillEnter">{{willEnter}}</span></p>
1314
<p>ionViewDidEnter: <span id="ionViewDidEnter">{{didEnter}}</span></p>
1415
<p>ionViewWillLeave: <span id="ionViewWillLeave">{{willLeave}}</span></p>

angular/test/test-app/src/app/router-link-page/router-link-page.component.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Component, OnInit, NgZone } from '@angular/core';
2+
import { IonRouterOutlet } from '@ionic/angular';
23

34
@Component({
45
selector: 'app-router-link-page',
@@ -11,20 +12,32 @@ export class RouterLinkPageComponent implements OnInit {
1112
didEnter = 0;
1213
willLeave = 0;
1314
didLeave = 0;
15+
canGoBack: boolean = null;
16+
17+
constructor(
18+
private ionRouterOutlet: IonRouterOutlet
19+
) {}
1420

1521
ngOnInit() {
1622
NgZone.assertInAngularZone();
23+
this.canGoBack = this.ionRouterOutlet.canGoBack();
1724
this.onInit++;
1825
}
1926

2027
ionViewWillEnter() {
2128
if (this.onInit !== 1) {
2229
throw new Error('ngOnInit was not called');
2330
}
31+
if (this.canGoBack !== this.ionRouterOutlet.canGoBack()) {
32+
throw new Error('canGoBack() changed');
33+
}
2434
NgZone.assertInAngularZone();
2535
this.willEnter++;
2636
}
2737
ionViewDidEnter() {
38+
if (this.canGoBack !== this.ionRouterOutlet.canGoBack()) {
39+
throw new Error('canGoBack() changed');
40+
}
2841
NgZone.assertInAngularZone();
2942
this.didEnter++;
3043
}

core/scripts/swiper.rollup.config.js

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,6 @@ export default {
77
format: 'es'
88
},
99
plugins: [
10-
resolve({
11-
module: true
12-
})
10+
resolve()
1311
]
1412
};

0 commit comments

Comments
 (0)