fix(router): transition race condition

fixes #14873
fixes #15090
This commit is contained in:
Manu Mtz.-Almeida
2018-08-09 02:40:27 +02:00
parent 01690452e9
commit 50ad1e7c5a
9 changed files with 143 additions and 59 deletions

View File

@ -162,11 +162,11 @@ export class IonRouterOutlet implements OnDestroy, OnInit {
} }
function emitEvent(el: HTMLElement) { function emitEvent(el: HTMLElement) {
const event = new CustomEvent('ionRouterOutletActivated', { const ev = new CustomEvent('ionRouterOutletActivated', {
bubbles: true, bubbles: true,
cancelable: true, cancelable: true,
}); });
el.dispatchEvent(event); el.dispatchEvent(ev);
} }
class OutletInjector implements Injector { class OutletInjector implements Injector {

View File

@ -39,7 +39,6 @@ export class StackController {
const leavingView = this.getActive(); const leavingView = this.getActive();
this.insertView(enteringView, direction); this.insertView(enteringView, direction);
await this.transition(enteringView, leavingView, direction, animated, this.canGoBack(1)); await this.transition(enteringView, leavingView, direction, animated, this.canGoBack(1));
this.cleanup(); this.cleanup();
} }

View File

@ -1,4 +1,5 @@
import { APP_INITIALIZER, ModuleWithProviders, NgModule } from '@angular/core'; import { APP_INITIALIZER, ModuleWithProviders, NgModule } from '@angular/core';
import { IonicConfig } from '@ionic/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { appInitialize } from './app-initialize'; import { appInitialize } from './app-initialize';
@ -133,7 +134,7 @@ const PROVIDERS = [
imports: [CommonModule] imports: [CommonModule]
}) })
export class IonicModule { export class IonicModule {
static forRoot(config?: { [key: string]: any }): ModuleWithProviders { static forRoot(config?: IonicConfig): ModuleWithProviders {
return { return {
ngModule: IonicModule, ngModule: IonicModule,
providers: [ providers: [

View File

@ -1,11 +1,11 @@
import { Config as CoreConfig } from '@ionic/core'; import { Config as CoreConfig, IonicConfig } from '@ionic/core';
import { InjectionToken } from '@angular/core'; import { InjectionToken } from '@angular/core';
import { IonicWindow } from '../types/interfaces'; import { IonicWindow } from '../types/interfaces';
export class Config { export class Config {
get(key: string, fallback?: any): any { get(key: keyof IonicConfig, fallback?: any): any {
const c = getConfig(); const c = getConfig();
if (c) { if (c) {
return c.get(key, fallback); return c.get(key, fallback);
@ -13,7 +13,7 @@ export class Config {
return null; return null;
} }
getBoolean(key: string, fallback?: boolean): boolean { getBoolean(key: keyof IonicConfig, fallback?: boolean): boolean {
const c = getConfig(); const c = getConfig();
if (c) { if (c) {
return c.getBoolean(key, fallback); return c.getBoolean(key, fallback);
@ -21,7 +21,7 @@ export class Config {
return false; return false;
} }
getNumber(key: string, fallback?: number): number { getNumber(key: keyof IonicConfig, fallback?: number): number {
const c = getConfig(); const c = getConfig();
if (c) { if (c) {
return c.getNumber(key, fallback); return c.getNumber(key, fallback);
@ -29,7 +29,7 @@ export class Config {
return 0; return 0;
} }
set(key: string, value?: any) { set(key: keyof IonicConfig, value?: any) {
const c = getConfig(); const c = getConfig();
if (c) { if (c) {
c.set(key, value); c.set(key, value);

View File

@ -11,9 +11,9 @@ import { attachComponent, detachComponent } from '../../utils/framework-delegate
}) })
export class RouterOutlet implements NavOutlet { export class RouterOutlet implements NavOutlet {
private isTransitioning = false;
private activeEl: HTMLElement | undefined; private activeEl: HTMLElement | undefined;
private activeComponent: any; private activeComponent: any;
private waitPromise?: Promise<void>;
mode!: Mode; mode!: Mode;
@ -36,7 +36,6 @@ export class RouterOutlet implements NavOutlet {
if (this.animated === undefined) { if (this.animated === undefined) {
this.animated = this.config.getBoolean('animate', true); this.animated = this.config.getBoolean('animate', true);
} }
this.ionNavWillLoad.emit(); this.ionNavWillLoad.emit();
} }
@ -49,20 +48,19 @@ export class RouterOutlet implements NavOutlet {
*/ */
@Method() @Method()
async setRoot(component: ComponentRef, params?: ComponentProps, opts?: RouterOutletOptions): Promise<boolean> { async setRoot(component: ComponentRef, params?: ComponentProps, opts?: RouterOutletOptions): Promise<boolean> {
if (this.isTransitioning || this.activeComponent === component) { if (this.activeComponent === component) {
return false; return false;
} }
this.activeComponent = component;
// attach entering view to DOM // attach entering view to DOM
const enteringEl = await attachComponent(this.delegate, this.el, component, ['ion-page', 'ion-page-invisible'], params);
const leavingEl = this.activeEl; const leavingEl = this.activeEl;
const enteringEl = await attachComponent(this.delegate, this.el, component, ['ion-page', 'ion-page-invisible'], params);
this.activeComponent = component;
this.activeEl = enteringEl;
// commit animation // commit animation
await this.commit(enteringEl, leavingEl, opts); await this.commit(enteringEl, leavingEl, opts);
// remove leaving view
this.activeEl = enteringEl;
detachComponent(this.delegate, leavingEl); detachComponent(this.delegate, leavingEl);
return true; return true;
@ -71,35 +69,15 @@ export class RouterOutlet implements NavOutlet {
/** @hidden */ /** @hidden */
@Method() @Method()
async commit(enteringEl: HTMLElement, leavingEl: HTMLElement | undefined, opts?: RouterOutletOptions): Promise<boolean> { async commit(enteringEl: HTMLElement, leavingEl: HTMLElement | undefined, opts?: RouterOutletOptions): Promise<boolean> {
// isTransitioning acts as a lock to prevent reentering const unlock = await this.lock();
if (this.isTransitioning || leavingEl === enteringEl) { let changed = false;
return false; try {
changed = await this.transition(enteringEl, leavingEl, opts);
} catch (e) {
console.error(e);
} }
this.isTransitioning = true; unlock();
return changed;
// emit nav will change event
this.ionNavWillChange.emit();
opts = opts || {};
const { mode, queue, animated, animationCtrl, win, el } = this;
await transition({
mode,
queue,
animated,
animationCtrl,
window: win,
enteringEl,
leavingEl,
baseEl: el,
...opts
});
this.isTransitioning = false;
// emit nav changed event
this.ionNavDidChange.emit();
return true;
} }
/** @hidden */ /** @hidden */
@ -125,6 +103,48 @@ export class RouterOutlet implements NavOutlet {
} : undefined; } : undefined;
} }
private async lock() {
const p = this.waitPromise;
let resolve!: () => void;
this.waitPromise = new Promise(r => resolve = r);
if (p) {
await p;
}
return resolve;
}
async transition(enteringEl: HTMLElement, leavingEl: HTMLElement | undefined, opts?: RouterOutletOptions): Promise<boolean> {
// isTransitioning acts as a lock to prevent reentering
if (leavingEl === enteringEl) {
return false;
}
// emit nav will change event
this.ionNavWillChange.emit();
opts = opts || {};
const { mode, queue, animated, animationCtrl, win, el } = this;
await transition({
mode,
queue,
animated,
animationCtrl,
window: win,
enteringEl,
leavingEl,
baseEl: el,
...opts
});
// emit nav changed event
this.ionNavDidChange.emit();
return true;
}
render() { render() {
return [ return [
this.mode === 'ios' && <div class="nav-decor"/>, this.mode === 'ios' && <div class="nav-decor"/>,

View File

@ -75,7 +75,28 @@
<ion-route url="/two" component="page-two"> </ion-route> <ion-route url="/two" component="page-two"> </ion-route>
<ion-route url="/page-3" component="page-three"> </ion-route> <ion-route url="/page-3" component="page-three"> </ion-route>
</ion-router> </ion-router>
<ion-router-outlet></ion-router-outlet>
<ion-split-pane>
<ion-menu>
<ion-header>
<ion-toolbar>
<ion-title>Menu</ion-title>
</ion-toolbar>
</ion-header>
<ion-content>
<ion-item href="#/">
<ion-label>Page 1</ion-label>
</ion-item>
<ion-item href="#/two">
<ion-label>Page 2</ion-label>
</ion-item>
<ion-item href="#/page-3">
<ion-label>Page 3</ion-label>
</ion-item>
</ion-content>
</ion-menu>
<ion-router-outlet main></ion-router-outlet>
</ion-split-pane>
</ion-app> </ion-app>
</body> </body>

View File

@ -19,6 +19,7 @@ export class Router {
private busy = false; private busy = false;
private state = 0; private state = 0;
private lastState = 0; private lastState = 0;
private waitPromise?: Promise<void>;
@Element() el!: HTMLElement; @Element() el!: HTMLElement;
@ -82,10 +83,10 @@ export class Router {
/** Navigate to the specified URL */ /** Navigate to the specified URL */
@Method() @Method()
push(url: string, direction: RouterDirection = 'forward') { push(url: string, direction: RouterDirection = 'forward') {
const path = parsePath(url);
const intent = DIRECTION_TO_INTENT[direction];
console.debug('[ion-router] URL pushed -> updating nav', url, direction); console.debug('[ion-router] URL pushed -> updating nav', url, direction);
const path = parsePath(url);
const intent = DIRECTION_TO_INTENT[direction];
this.setPath(path, intent); this.setPath(path, intent);
return this.writeNavStateRoot(path, intent); return this.writeNavStateRoot(path, intent);
} }
@ -103,6 +104,7 @@ export class Router {
@Method() @Method()
async navChanged(intent: number): Promise<boolean> { async navChanged(intent: number): Promise<boolean> {
if (this.busy) { if (this.busy) {
console.warn('[ion-router] router is busy, navChanged was cancelled');
return false; return false;
} }
const { ids, outlet } = readNavState(this.win.document.body); const { ids, outlet } = readNavState(this.win.document.body);
@ -122,7 +124,7 @@ export class Router {
console.debug('[ion-router] nav changed -> update URL', ids, path); console.debug('[ion-router] nav changed -> update URL', ids, path);
this.setPath(path, intent); this.setPath(path, intent);
await this.writeNavState(outlet, chain, RouterIntent.None, path, null, ids.length); await this.safeWriteNavState(outlet, chain, RouterIntent.None, path, null, ids.length);
return true; return true;
} }
@ -157,9 +159,6 @@ export class Router {
} }
private async writeNavStateRoot(path: string[] | null, intent: RouterIntent): Promise<boolean> { private async writeNavStateRoot(path: string[] | null, intent: RouterIntent): Promise<boolean> {
if (this.busy) {
return false;
}
if (!path) { if (!path) {
console.error('[ion-router] URL is not part of the routing set'); console.error('[ion-router] URL is not part of the routing set');
return false; return false;
@ -184,7 +183,34 @@ export class Router {
} }
// write DOM give // write DOM give
return this.writeNavState(this.win.document.body, chain, intent, path, redirectFrom); return this.safeWriteNavState(this.win.document.body, chain, intent, path, redirectFrom);
}
private async safeWriteNavState(
node: HTMLElement | undefined, chain: RouteChain, intent: RouterIntent,
path: string[], redirectFrom: string[] | null,
index = 0
): Promise<boolean> {
const unlock = await this.lock();
let changed = false;
try {
changed = await this.writeNavState(node, chain, intent, path, redirectFrom, index);
} catch (e) {
console.error(e);
}
unlock();
return changed;
}
private async lock() {
const p = this.waitPromise;
let resolve!: () => void;
this.waitPromise = new Promise(r => resolve = r);
if (p) {
await p;
}
return resolve;
} }
private async writeNavState( private async writeNavState(
@ -193,6 +219,7 @@ export class Router {
index = 0 index = 0
): Promise<boolean> { ): Promise<boolean> {
if (this.busy) { if (this.busy) {
console.warn('[ion-router] router is busy, transition was cancelled');
return false; return false;
} }
this.busy = true; this.busy = true;

View File

@ -1,5 +1,12 @@
import { Mode } from '../interface';
export interface IonicConfig { export interface IonicConfig {
/**
* The mode determines which platform styles to use.
* Possible values are: `"ios"` or `"md"`.
*/
mode?: Mode;
isDevice?: boolean; isDevice?: boolean;
statusbarPadding?: boolean; statusbarPadding?: boolean;
inputShims?: boolean; inputShims?: boolean;
@ -12,7 +19,6 @@ export interface IonicConfig {
pickerSpinner?: string; pickerSpinner?: string;
refreshingIcon?: string; refreshingIcon?: string;
refreshingSpinner?: string; refreshingSpinner?: string;
mode?: string;
menuType?: string; menuType?: string;
scrollPadding?: string; scrollPadding?: string;
inputBlurring?: string; inputBlurring?: string;

View File

@ -70,6 +70,8 @@ async function animation(animationBuilder: AnimationBuilder, opts: TransitionOpt
fireWillEvents(opts.window, opts.enteringEl, opts.leavingEl); fireWillEvents(opts.window, opts.enteringEl, opts.leavingEl);
await playTransition(trns, opts); await playTransition(trns, opts);
markVisible(opts);
if (trns.hasCompleted) { if (trns.hasCompleted) {
fireDidEvents(opts.window, opts.enteringEl, opts.leavingEl); fireDidEvents(opts.window, opts.enteringEl, opts.leavingEl);
} }
@ -79,17 +81,25 @@ async function animation(animationBuilder: AnimationBuilder, opts: TransitionOpt
async function noAnimation(opts: TransitionOptions): Promise<null> { async function noAnimation(opts: TransitionOptions): Promise<null> {
const enteringEl = opts.enteringEl; const enteringEl = opts.enteringEl;
const leavingEl = opts.leavingEl; const leavingEl = opts.leavingEl;
await waitForReady(opts, false);
markVisible(opts);
fireWillEvents(opts.window, enteringEl, leavingEl);
fireDidEvents(opts.window, enteringEl, leavingEl);
return null;
}
async function markVisible(opts: TransitionOptions) {
const enteringEl = opts.enteringEl;
const leavingEl = opts.leavingEl;
if (enteringEl) { if (enteringEl) {
enteringEl.classList.remove('ion-page-invisible'); enteringEl.classList.remove('ion-page-invisible');
} }
if (leavingEl) { if (leavingEl) {
leavingEl.classList.remove('ion-page-invisible'); leavingEl.classList.remove('ion-page-invisible');
} }
await waitForReady(opts, false);
fireWillEvents(opts.window, enteringEl, leavingEl);
fireDidEvents(opts.window, enteringEl, leavingEl);
return null;
} }
async function waitForReady(opts: TransitionOptions, defaultDeep: boolean) { async function waitForReady(opts: TransitionOptions, defaultDeep: boolean) {