Skip to content

Commit 7a086ee

Browse files
fix(uiCanExit): Only process uiCanExit hook once during redirects
Closes #3308
1 parent 8fe5b1f commit 7a086ee

File tree

3 files changed

+72
-20
lines changed

3 files changed

+72
-20
lines changed

src/directives/viewDirective.ts

+40-10
Original file line numberDiff line numberDiff line change
@@ -335,9 +335,14 @@ function $ViewDirective($view: ViewService, $animate: any, $uiViewScroll: any, $
335335
return directive;
336336
}];
337337

338-
$ViewDirectiveFill.$inject = ['$compile', '$controller', '$transitions', '$view', '$timeout'];
338+
$ViewDirectiveFill.$inject = ['$compile', '$controller', '$transitions', '$view', '$q', '$timeout'];
339339
/** @hidden */
340-
function $ViewDirectiveFill ($compile: ICompileService, $controller: IControllerService, $transitions: TransitionService, $view: ViewService, $timeout: ITimeoutService) {
340+
function $ViewDirectiveFill($compile: angular.ICompileService,
341+
$controller: angular.IControllerService,
342+
$transitions: TransitionService,
343+
$view: ViewService,
344+
$q: angular.IQService,
345+
$timeout: ITimeoutService) {
341346
const getControllerAs = parse('viewDecl.controllerAs');
342347
const getResolveAs = parse('viewDecl.resolveAs');
343348

@@ -384,7 +389,7 @@ function $ViewDirectiveFill ($compile: ICompileService, $controller: IController
384389
$element.data('$ngControllerController', controllerInstance);
385390
$element.children().data('$ngControllerController', controllerInstance);
386391

387-
registerControllerCallbacks($transitions, controllerInstance, scope, cfg);
392+
registerControllerCallbacks($q, $transitions, controllerInstance, scope, cfg);
388393
}
389394

390395
// Wait for the component to appear in the DOM
@@ -402,7 +407,7 @@ function $ViewDirectiveFill ($compile: ICompileService, $controller: IController
402407

403408
let deregisterWatch = scope.$watch(getComponentController, function(ctrlInstance) {
404409
if (!ctrlInstance) return;
405-
registerControllerCallbacks($transitions, ctrlInstance, scope, cfg);
410+
registerControllerCallbacks($q, $transitions, ctrlInstance, scope, cfg);
406411
deregisterWatch();
407412
});
408413
}
@@ -415,15 +420,23 @@ function $ViewDirectiveFill ($compile: ICompileService, $controller: IController
415420

416421
/** @hidden */
417422
let hasComponentImpl = typeof (angular as any).module('ui.router')['component'] === 'function';
423+
/** @hidden incrementing id */
424+
let _uiCanExitId = 0;
418425

419426
/** @hidden TODO: move these callbacks to $view and/or `/hooks/components.ts` or something */
420-
function registerControllerCallbacks($transitions: TransitionService, controllerInstance: Ng1Controller, $scope: IScope, cfg: Ng1ViewConfig) {
427+
function registerControllerCallbacks($q: angular.IQService,
428+
$transitions: TransitionService,
429+
controllerInstance: Ng1Controller,
430+
$scope: IScope,
431+
cfg: Ng1ViewConfig) {
421432
// Call $onInit() ASAP
422-
if (isFunction(controllerInstance.$onInit) && !(cfg.viewDecl.component && hasComponentImpl)) controllerInstance.$onInit();
433+
if (isFunction(controllerInstance.$onInit) && !(cfg.viewDecl.component && hasComponentImpl)) {
434+
controllerInstance.$onInit();
435+
}
423436

424437
let viewState: Ng1StateDeclaration = tail(cfg.path).state.self;
425438

426-
var hookOptions: HookRegOptions = { bind: controllerInstance };
439+
let hookOptions: HookRegOptions = { bind: controllerInstance };
427440
// Add component-level hook for onParamsChange
428441
if (isFunction(controllerInstance.uiOnParamsChanged)) {
429442
let resolveContext: ResolveContext = new ResolveContext(cfg.path);
@@ -450,7 +463,7 @@ function registerControllerCallbacks($transitions: TransitionService, controller
450463
if (changedToParams.length) {
451464
let changedKeys: string[] = changedToParams.map(x => x.id);
452465
// Filter the params to only changed/new to params. `$transition$.params()` may be used to get all params.
453-
var newValues = filter(toParams, (val, key) => changedKeys.indexOf(key) !== -1);
466+
let newValues = filter(toParams, (val, key) => changedKeys.indexOf(key) !== -1);
454467
controllerInstance.uiOnParamsChanged(newValues, $transition$);
455468
}
456469
};
@@ -459,8 +472,25 @@ function registerControllerCallbacks($transitions: TransitionService, controller
459472

460473
// Add component-level hook for uiCanExit
461474
if (isFunction(controllerInstance.uiCanExit)) {
462-
var criteria = {exiting: viewState.name};
463-
$scope.$on('$destroy', <any> $transitions.onBefore(criteria, controllerInstance.uiCanExit, hookOptions));
475+
let id = _uiCanExitId++;
476+
let cacheProp = '_uiCanExitIds';
477+
478+
// Returns true if a redirect transition already answered truthy
479+
const prevTruthyAnswer = (trans: Transition) =>
480+
!!trans && (trans[cacheProp] && trans[cacheProp][id] === true || prevTruthyAnswer(trans.redirectedFrom()));
481+
482+
// If a user answered yes, but the transition was later redirected, don't also ask for the new redirect transition
483+
const wrappedHook = (trans: Transition) => {
484+
let promise, ids = trans[cacheProp] = trans[cacheProp] || {};
485+
if (!prevTruthyAnswer(trans)) {
486+
promise = $q.when(controllerInstance.uiCanExit(trans));
487+
promise.then(val => ids[id] = (val !== false));
488+
}
489+
return promise;
490+
};
491+
492+
let criteria = {exiting: viewState.name};
493+
$scope.$on('$destroy', <any> $transitions.onBefore(criteria, wrappedHook, hookOptions));
464494
}
465495
}
466496

src/interface.ts

+4-5
Original file line numberDiff line numberDiff line change
@@ -677,15 +677,14 @@ export interface Ng1Controller {
677677
* - The new Transition will exit the view's state
678678
*
679679
* Called with:
680-
* - This callback is injected in the new Transition's context
680+
* - The new Transition
681681
*
682682
* Relevant return Values:
683683
* - `false`: The transition is cancelled.
684684
* - A rejected promise: The transition is cancelled.
685685
* - [[TargetState]]: The transition is redirected to the new target state.
686686
* - Anything else: the transition will continue normally (the state and view will be deactivated)
687687
*
688-
*
689688
* #### Example:
690689
* ```js
691690
* app.component('myComponent', {
@@ -706,10 +705,10 @@ export interface Ng1Controller {
706705
* }
707706
* ```
708707
*
709-
*
710-
* @return a value, or a promise for a value.
708+
* @param transition the new Transition that is about to exit the component's state
709+
* @return a HookResult, or a promise for a HookResult
711710
*/
712-
uiCanExit(): HookResult;
711+
uiCanExit(transition: Transition): HookResult;
713712
}
714713

715714
/**

test/viewHookSpec.ts

+28-5
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import * as angular from "angular";
22
import "./util/matchers";
3+
import { StateService } from "ui-router-core";
4+
35
declare var inject;
46

57
describe("view hooks", () => {
6-
let app, ctrl, $state, $q, $timeout, log = "";
8+
let app, ctrl, $state: StateService, $q, $timeout: angular.ITimeoutService, log = "";
79
let component = {
810
bindings: { cmpdata: '<' },
911
template: '{{$ctrl.cmpdata}}',
@@ -40,6 +42,7 @@ describe("view hooks", () => {
4042
$stateProvider.state({ name: "foo", url: "/foo", component: 'foo' });
4143
$stateProvider.state({ name: "bar", url: "/bar", component: 'bar' });
4244
$stateProvider.state({ name: "baz", url: "/baz", component: 'baz' });
45+
$stateProvider.state({ name: "redirect", redirectTo: 'baz' });
4346
}));
4447

4548
beforeEach(angular['mock'].module('viewhooks', 'ui.router'));
@@ -93,14 +96,12 @@ describe("view hooks", () => {
9396
it("can redirect the transition", () => {
9497
ctrl.prototype.uiCanExit = function(trans) {
9598
log += "canexit;";
96-
if (trans.to().name !== 'baz') {
97-
return trans.router.stateService.target('baz');
98-
}
99+
return trans.router.stateService.target('baz');
99100
};
100101
initial();
101102

102103
$state.go('bar'); $q.flush(); $timeout.flush();
103-
expect(log).toBe('canexit;canexit;'); // first: redirects, second: allows the transition
104+
expect(log).toBe('canexit;');
104105
expect($state.current.name).toBe('baz');
105106
});
106107

@@ -162,5 +163,27 @@ describe("view hooks", () => {
162163
expect($state.current.name).toBe('bar');
163164
});
164165

166+
// Test for https://github.com/angular-ui/ui-router/issues/3308
167+
it('should trigger once when answered truthy even if redirected', () => {
168+
ctrl.prototype.uiCanExit = function() { log += "canexit;"; return true; };
169+
initial();
170+
171+
$state.go('redirect'); $q.flush(); $timeout.flush();
172+
expect(log).toBe('canexit;');
173+
expect($state.current.name).toBe('baz');
174+
});
175+
176+
// Test for https://github.com/angular-ui/ui-router/issues/3308
177+
it('should trigger only once if returns a redirect', () => {
178+
ctrl.prototype.uiCanExit = function() {
179+
log += "canexit;";
180+
return $state.target('bar');
181+
};
182+
initial();
183+
184+
$state.go('redirect'); $q.flush(); $timeout.flush();
185+
expect(log).toBe('canexit;');
186+
expect($state.current.name).toBe('bar');
187+
});
165188
});
166189
});

0 commit comments

Comments
 (0)