Skip to content

Commit bd1bd0b

Browse files
feat(transition): Ignore duplicate transitions (double clicks)
Previously, any calls to state.go would supersede the running transition. If the user starts a transition, then immediately starts another identical transition, the new transition supersedes the old transition. Now, if the new transition is identical to the running transition, the new transition is ignored. Identical basically means: same destination state and parameter values. Closes #42
1 parent 5a54b1d commit bd1bd0b

21 files changed

+296
-112
lines changed

src/common/trace.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ import {Transition} from "../transition/transition";
3939
import {ActiveUIView, ViewConfig, ViewContext} from "../view/interface";
4040
import {stringify, functionToString, maxLength, padString} from "./strings";
4141
import {Resolvable} from "../resolve/resolvable";
42-
import {PathNode} from "../path/node";
42+
import {PathNode} from "../path/pathNode";
4343
import {PolicyWhen} from "../resolve/interface";
4444
import {TransitionHook} from "../transition/transitionHook";
4545
import {HookResult} from "../transition/interface";

src/hooks/ignoredTransition.ts

+15-3
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { trace } from '../common/trace';
33
import { Rejection } from '../transition/rejectFactory';
44
import { TransitionService } from '../transition/transitionService';
55
import { Transition } from '../transition/transition';
6+
import {PathUtils} from '../path/pathFactory';
67

78
/**
89
* A [[TransitionHookFn]] that skips a transition if it should be ignored
@@ -13,10 +14,21 @@ import { Transition } from '../transition/transition';
1314
* then the transition is ignored and not processed.
1415
*/
1516
function ignoredHook(trans: Transition) {
16-
if (trans.ignored()) {
17-
trace.traceTransitionIgnored(this);
18-
return Rejection.ignored().toPromise();
17+
const ignoredReason = trans._ignoredReason();
18+
if (!ignoredReason) return;
19+
20+
trace.traceTransitionIgnored(trans);
21+
22+
const pending = trans.router.globals.transition;
23+
24+
// The user clicked a link going back to the *current state* ('A')
25+
// However, there is also a pending transition in flight (to 'B')
26+
// Abort the transition to 'B' because the user now wants to be back at 'A'.
27+
if (ignoredReason === 'SameAsCurrent' && pending) {
28+
pending.abort();
1929
}
30+
31+
return Rejection.ignored().toPromise();
2032
}
2133

2234
export const registerIgnoredTransitionHook = (transitionService: TransitionService) =>

src/path/index.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
/** @module path */ /** for typedoc */
2-
export * from "./node";
2+
export * from "./pathNode";
33
export * from "./pathFactory";

src/path/pathFactory.ts

+54-10
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
/** @module path */ /** for typedoc */
22

3-
import {extend, find, pick, omit, tail, mergeR, values, unnestR, Predicate, inArray} from "../common/common";
3+
import {
4+
extend, find, pick, omit, tail, mergeR, values, unnestR, Predicate, inArray, arrayTuples,
5+
} from "../common/common";
46
import {prop, propEq, not} from "../common/hof";
57

68
import {RawParams} from "../params/interface";
@@ -10,13 +12,14 @@ import {_ViewDeclaration} from "../state/interface";
1012

1113
import {StateObject} from "../state/stateObject";
1214
import {TargetState} from "../state/targetState";
13-
import {PathNode} from "../path/node";
15+
import {GetParamsFn, PathNode} from "./pathNode";
1416
import {ViewService} from "../view/view";
17+
import { Param } from '../params/param';
1518

1619
/**
1720
* This class contains functions which convert TargetStates, Nodes and paths from one type to another.
1821
*/
19-
export class PathFactory {
22+
export class PathUtils {
2023

2124
constructor() { }
2225

@@ -33,9 +36,9 @@ export class PathFactory {
3336

3437
/** Given a fromPath: PathNode[] and a TargetState, builds a toPath: PathNode[] */
3538
static buildToPath(fromPath: PathNode[], targetState: TargetState): PathNode[] {
36-
let toPath: PathNode[] = PathFactory.buildPath(targetState);
39+
let toPath: PathNode[] = PathUtils.buildPath(targetState);
3740
if (targetState.options().inherit) {
38-
return PathFactory.inheritParams(fromPath, toPath, Object.keys(targetState.params()));
41+
return PathUtils.inheritParams(fromPath, toPath, Object.keys(targetState.params()));
3942
}
4043
return toPath;
4144
}
@@ -49,7 +52,7 @@ export class PathFactory {
4952
// Only apply the viewConfigs to the nodes for the given states
5053
path.filter(node => inArray(states, node.state)).forEach(node => {
5154
let viewDecls: _ViewDeclaration[] = values(node.state.views || {});
52-
let subPath = PathFactory.subPath(path, n => n === node);
55+
let subPath = PathUtils.subPath(path, n => n === node);
5356
let viewConfigs: ViewConfig[][] = viewDecls.map(view => $view.createViewConfig(subPath, view));
5457
node.views = viewConfigs.reduce(unnestR, []);
5558
});
@@ -97,15 +100,18 @@ export class PathFactory {
97100
return <PathNode[]> toPath.map(makeInheritedParamsNode);
98101
}
99102

103+
static nonDynamicParams = (node: PathNode): Param[] =>
104+
node.state.parameters({ inherit: false })
105+
.filter(param => !param.dynamic);
106+
100107
/**
101108
* Computes the tree changes (entering, exiting) between a fromPath and toPath.
102109
*/
103110
static treeChanges(fromPath: PathNode[], toPath: PathNode[], reloadState: StateObject): TreeChanges {
104111
let keep = 0, max = Math.min(fromPath.length, toPath.length);
105-
const staticParams = (state: StateObject) =>
106-
state.parameters({ inherit: false }).filter(not(prop('dynamic'))).map(prop('id'));
112+
107113
const nodesMatch = (node1: PathNode, node2: PathNode) =>
108-
node1.equals(node2, staticParams(node1.state));
114+
node1.equals(node2, PathUtils.nonDynamicParams);
109115

110116
while (keep < max && fromPath[keep].state !== reloadState && nodesMatch(fromPath[keep], toPath[keep])) {
111117
keep++;
@@ -132,6 +138,43 @@ export class PathFactory {
132138
return { from, to, retained, exiting, entering };
133139
}
134140

141+
/**
142+
* Returns a new path which is: the subpath of the first path which matches the second path.
143+
*
144+
* The new path starts from root and contains any nodes that match the nodes in the second path.
145+
* It stops before the first non-matching node.
146+
*
147+
* Nodes are compared using their state property and their parameter values.
148+
* If a `paramsFn` is provided, only the [[Param]] returned by the function will be considered when comparing nodes.
149+
*
150+
* @param pathA the first path
151+
* @param pathB the second path
152+
* @param paramsFn a function which returns the parameters to consider when comparing
153+
*
154+
* @returns an array of PathNodes from the first path which match the nodes in the second path
155+
*/
156+
static matching(pathA: PathNode[], pathB: PathNode[], paramsFn?: GetParamsFn): PathNode[] {
157+
let done = false;
158+
let tuples: PathNode[][] = arrayTuples(pathA, pathB);
159+
return tuples.reduce((matching, [nodeA, nodeB]) => {
160+
done = done || !nodeA.equals(nodeB, paramsFn);
161+
return done ? matching : matching.concat(nodeA);
162+
}, []);
163+
}
164+
165+
/**
166+
* Returns true if two paths are identical.
167+
*
168+
* @param pathA
169+
* @param pathB
170+
* @param paramsFn a function which returns the parameters to consider when comparing
171+
* @returns true if the the states and parameter values for both paths are identical
172+
*/
173+
static equals(pathA: PathNode[], pathB: PathNode[], paramsFn?: GetParamsFn): boolean {
174+
return pathA.length === pathB.length &&
175+
PathUtils.matching(pathA, pathB, paramsFn).length === pathA.length;
176+
}
177+
135178
/**
136179
* Return a subpath of a path, which stops at the first matching node
137180
*
@@ -149,5 +192,6 @@ export class PathFactory {
149192
}
150193

151194
/** Gets the raw parameter values from a path */
152-
static paramValues = (path: PathNode[]) => path.reduce((acc, node) => extend(acc, node.paramValues), {});
195+
static paramValues = (path: PathNode[]) =>
196+
path.reduce((acc, node) => extend(acc, node.paramValues), {});
153197
}
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/** @module path */ /** for typedoc */
2-
import {extend, applyPairs, find, allTrueR} from "../common/common";
2+
import {extend, applyPairs, find, allTrueR, pairs, arrayTuples} from "../common/common";
33
import {propEq} from "../common/hof";
44
import {StateObject} from "../state/stateObject";
55
import {RawParams} from "../params/interface";
@@ -8,6 +8,8 @@ import {Resolvable} from "../resolve/resolvable";
88
import {ViewConfig} from "../view/interface";
99

1010
/**
11+
* @internalapi
12+
*
1113
* A node in a [[TreeChanges]] path
1214
*
1315
* For a [[TreeChanges]] path, this class holds the stateful information for a single node in the path.
@@ -27,19 +29,19 @@ export class PathNode {
2729
public views: ViewConfig[];
2830

2931
/** Creates a copy of a PathNode */
30-
constructor(state: PathNode);
32+
constructor(node: PathNode);
3133
/** Creates a new (empty) PathNode for a State */
3234
constructor(state: StateObject);
33-
constructor(stateOrPath: any) {
34-
if (stateOrPath instanceof PathNode) {
35-
let node: PathNode = stateOrPath;
35+
constructor(stateOrNode: any) {
36+
if (stateOrNode instanceof PathNode) {
37+
let node: PathNode = stateOrNode;
3638
this.state = node.state;
3739
this.paramSchema = node.paramSchema.slice();
3840
this.paramValues = extend({}, node.paramValues);
3941
this.resolvables = node.resolvables.slice();
4042
this.views = node.views && node.views.slice();
4143
} else {
42-
let state: StateObject = stateOrPath;
44+
let state: StateObject = stateOrNode;
4345
this.state = state;
4446
this.paramSchema = state.parameters({ inherit: false });
4547
this.paramValues = {};
@@ -63,42 +65,35 @@ export class PathNode {
6365
* @returns true if the state and parameter values for another PathNode are
6466
* equal to the state and param values for this PathNode
6567
*/
66-
equals(node: PathNode, keys = this.paramSchema.map(p => p.id)): boolean {
67-
const paramValsEq = (key: string) =>
68-
this.parameter(key).type.equals(this.paramValues[key], node.paramValues[key]);
69-
return this.state === node.state && keys.map(paramValsEq).reduce(allTrueR, true);
70-
}
71-
72-
/** Returns a clone of the PathNode */
73-
static clone(node: PathNode) {
74-
return new PathNode(node);
68+
equals(node: PathNode, paramsFn?: GetParamsFn): boolean {
69+
const diff = this.diff(node, paramsFn);
70+
return diff && diff.length === 0;
7571
}
7672

7773
/**
78-
* Returns a new path which is a subpath of the first path which matched the second path.
74+
* Finds Params with different parameter values on another PathNode.
7975
*
80-
* The new path starts from root and contains any nodes that match the nodes in the second path.
81-
* Nodes are compared using their state property and parameter values.
76+
* Given another node (of the same state), finds the parameter values which differ.
77+
* Returns the [[Param]] (schema objects) whose parameter values differ.
8278
*
83-
* @param pathA the first path
84-
* @param pathB the second path
85-
* @param ignoreDynamicParams don't compare dynamic parameter values
79+
* Given another node for a different state, returns `false`
80+
*
81+
* @param node The node to compare to
82+
* @param paramsFn A function that returns which parameters should be compared.
83+
* @returns The [[Param]]s which differ, or null if the two nodes are for different states
8684
*/
87-
static matching(pathA: PathNode[], pathB: PathNode[], ignoreDynamicParams = true): PathNode[] {
88-
let matching: PathNode[] = [];
89-
90-
for (let i = 0; i < pathA.length && i < pathB.length; i++) {
91-
let a = pathA[i], b = pathB[i];
85+
diff(node: PathNode, paramsFn?: GetParamsFn): Param[] | false {
86+
if (this.state !== node.state) return false;
9287

93-
if (a.state !== b.state) break;
94-
95-
let changedParams = Param.changed(a.paramSchema, a.paramValues, b.paramValues)
96-
.filter(param => !(ignoreDynamicParams && param.dynamic));
97-
if (changedParams.length) break;
98-
99-
matching.push(a);
100-
}
88+
const params: Param[] = paramsFn ? paramsFn(this) : this.paramSchema;
89+
return Param.changed(params, this.paramValues, node.paramValues);
90+
}
10191

102-
return matching
92+
/** Returns a clone of the PathNode */
93+
static clone(node: PathNode) {
94+
return new PathNode(node);
10395
}
104-
}
96+
}
97+
98+
/** @hidden */
99+
export type GetParamsFn = (pathNode: PathNode) => Param[];

src/resolve/resolvable.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {stringify} from "../common/strings";
1212
import {isFunction, isObject} from "../common/predicates";
1313
import {Transition} from "../transition/transition";
1414
import {StateObject} from "../state/stateObject";
15-
import {PathNode} from "../path/node";
15+
import {PathNode} from "../path/pathNode";
1616

1717

1818
// TODO: explicitly make this user configurable

src/resolve/resolveContext.ts

+4-4
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,10 @@ import { propEq, not } from "../common/hof";
55
import { trace } from "../common/trace";
66
import { services, $InjectorLike } from "../common/coreservices";
77
import { resolvePolicies, PolicyWhen, ResolvePolicy } from "./interface";
8-
import { PathNode } from "../path/node";
8+
import { PathNode } from "../path/pathNode";
99
import { Resolvable } from "./resolvable";
1010
import { StateObject } from "../state/stateObject";
11-
import { PathFactory } from "../path/pathFactory";
11+
import { PathUtils } from "../path/pathFactory";
1212
import { stringify } from "../common/strings";
1313
import { Transition } from "../transition/transition";
1414
import { UIInjector } from "../interface";
@@ -82,7 +82,7 @@ export class ResolveContext {
8282
* `let AB = ABCD.subcontext(a)`
8383
*/
8484
subContext(state: StateObject): ResolveContext {
85-
return new ResolveContext(PathFactory.subPath(this._path, node => node.state === state));
85+
return new ResolveContext(PathUtils.subPath(this._path, node => node.state === state));
8686
}
8787

8888
/**
@@ -164,7 +164,7 @@ export class ResolveContext {
164164
let node = this.findNode(resolvable);
165165
// Find which other resolvables are "visible" to the `resolvable` argument
166166
// subpath stopping at resolvable's node, or the whole path (if the resolvable isn't in the path)
167-
let subPath: PathNode[] = PathFactory.subPath(this._path, x => x === node) || this._path;
167+
let subPath: PathNode[] = PathUtils.subPath(this._path, x => x === node) || this._path;
168168
let availableResolvables: Resolvable[] = subPath
169169
.reduce((acc, node) => acc.concat(node.resolvables), []) //all of subpath's resolvables
170170
.filter(res => res !== resolvable); // filter out the `resolvable` argument

src/state/stateService.ts

+8-6
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ import { isDefined, isObject, isString } from '../common/predicates';
88
import { Queue } from '../common/queue';
99
import { services } from '../common/coreservices';
1010

11-
import { PathFactory } from '../path/pathFactory';
12-
import { PathNode } from '../path/node';
11+
import { PathUtils } from '../path/pathFactory';
12+
import { PathNode } from '../path/pathNode';
1313

1414
import { HookResult, TransitionOptions } from '../transition/interface';
1515
import { defaultTransOpts } from '../transition/transitionService';
@@ -93,7 +93,7 @@ export class StateService {
9393
* @internalapi
9494
*/
9595
private _handleInvalidTargetState(fromPath: PathNode[], toState: TargetState) {
96-
let fromState = PathFactory.makeTargetState(fromPath);
96+
let fromState = PathUtils.makeTargetState(fromPath);
9797
let globals = this.router.globals;
9898
const latestThing = () => globals.transitionHistory.peekTail();
9999
let latest = latestThing();
@@ -340,9 +340,11 @@ export class StateService {
340340
*/
341341
const rejectedTransitionHandler = (transition: Transition) => (error: any): Promise<any> => {
342342
if (error instanceof Rejection) {
343+
const isLatest = router.globals.lastStartedTransitionId === transition.$id;
344+
343345
if (error.type === RejectType.IGNORED) {
346+
isLatest && router.urlRouter.update();
344347
// Consider ignored `Transition.run()` as a successful `transitionTo`
345-
router.urlRouter.update();
346348
return services.$q.when(globals.current);
347349
}
348350

@@ -355,7 +357,7 @@ export class StateService {
355357
}
356358

357359
if (error.type === RejectType.ABORTED) {
358-
router.urlRouter.update();
360+
isLatest && router.urlRouter.update();
359361
return services.$q.reject(error);
360362
}
361363
}
@@ -593,7 +595,7 @@ export class StateService {
593595
if (!state || !state.lazyLoad) throw new Error("Can not lazy load " + stateOrName);
594596

595597
let currentPath = this.getCurrentPath();
596-
let target = PathFactory.makeTargetState(currentPath);
598+
let target = PathUtils.makeTargetState(currentPath);
597599
transition = transition || this.router.transitionService.create(currentPath, target);
598600

599601
return lazyLoadState(transition, state);

src/transition/hookBuilder.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
import {Transition} from "./transition";
1515
import {TransitionHook} from "./transitionHook";
1616
import {StateObject} from "../state/stateObject";
17-
import {PathNode} from "../path/node";
17+
import {PathNode} from "../path/pathNode";
1818
import {TransitionService} from "./transitionService";
1919
import {TransitionEventType} from "./transitionEventType";
2020
import {RegisteredHook} from "./hookRegistry";

src/transition/hookRegistry.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
*/ /** for typedoc */
55
import { extend, removeFrom, tail, values, identity, map } from "../common/common";
66
import {isString, isFunction} from "../common/predicates";
7-
import {PathNode} from "../path/node";
7+
import {PathNode} from "../path/pathNode";
88
import {
99
TransitionStateHookFn, TransitionHookFn, TransitionHookPhase, TransitionHookScope, IHookRegistry, PathType
1010
} from "./interface"; // has or is using

src/transition/interface.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {Predicate} from "../common/common";
77

88
import {Transition} from "./transition";
99
import {StateObject} from "../state/stateObject";
10-
import {PathNode} from "../path/node";
10+
import {PathNode} from "../path/pathNode";
1111
import {TargetState} from "../state/targetState";
1212
import {RegisteredHook} from "./hookRegistry";
1313

0 commit comments

Comments
 (0)