How do I replace my angular location without a controller refresh?
Asked Answered
B

5

7

Suppose I have an Angular app for editing eCards. Creating a new eCard uses a path like #/ecard/create and editing an existing eCard uses a path like #/ecard/:id. A tabbing system lets us have multiple eCards open for editing at a time.

We'd like an autosave feature like what users would expect from e.g. modern webmail or wiki software (or StackOverflow itself). We don't want to save an eCard draft the moment the user opens the Create form, which would give us a lot of drafts of blank eCards, so we start autosaving once the user starts typing.

I'd like to write code like this in our controller (this is simplified to not include e.g. error handling or stopping the autosave when the tab is closed, etc):

$scope.autosave = function () {
    ECardService.autosave($scope.eCard).then(function (response) {
        $location.path('/ecard/' + response.id).replace();
        $timeout($scope.autosave, AUTOSAVE_INTERVAL);
    });
};
$timeout($scope.autosave, AUTOSAVE_INTERVAL);

The above code works great, except for one thing: when the location changes, our controller reloads and the view re-renders. So if the user is in the middle of typing when the autosave completes, there's a brief flicker, and they lose their place.

I've considered several approaches to mitigate this problem:

1) Change the path to use the search path and set reloadOnSearch to false in the ngRoute configuration. So the path would change from #/ecard?id=create to e.g. #/ecard/id=123 and thus not force a reload. The problem is that I might have multiple eCards open and I do want changing from e.g. #/ecard/id=123 to #/ecard/id=321 to trigger a route change and reload the controller. So this isn't really feasible.

2) Don't bother editing the URL and deal with the back button giving a weird behavior in this case. This is tempting, but if a user opens their list of existing eCards and tries to open the specific eCard that has been saved, we want the tabbing system to recognize that it should just display the currently existing tab rather than open a new tab.
We could theoretically address this by updating our tabbing system to be smarter; instead of just checking the path, it could check both the path and the persistent id, which we could store somewhere. This would make the tabbing system significantly more complex, and that seems like overkill for this feature.

3) Only change the URL when the user is not actively editing, e.g. write a $scope.userIsIdle() function which returns true if it's been at least 10 seconds since the user made any edits, then update the path based on that. A simplified version of this would look something like:

$scope.updatePathWhenSafe = function (path) {
    if ($scope.userIsIdle()) {
        $location.path(path).replace();
    } else {
        $timeout(function () {
            $scope.updatePathWhenSafe(path);
        }, 1000);
    }
};

I ended up going with option #3; it was significantly simpler than option #2, but a lot more complicated to implement and test than I'd like, especially once I account for edge cases such as "what if the tab is no longer the active tab when this timeout fires?" I'd love for option #4 to be possible.

4) Go outside Angular to edit the current location and history, assuming this is necessary and possible. This would be my preferred solution, but my research indicates it's not safe/advisable to try to go around the $location service for changing your path or editing history. Is there some safe way to do this? It would make things so much simpler if I could just say, "Change the current path but don't reload the controller."

Is option #4 possible/feasible? If not, then is there a better way? Maybe some magical "Do it the angular way but somehow don't refresh the controller"?

Balladeer answered 4/3, 2014 at 16:25 Comment(6)
To me option (2) sounds like the obvious way to go. But without understanding how your tabbing system works it difficult to look into this option further. You say the tabbing system can have multiple eCards open for editing, how do you distinguish between the different eCards if you say you use the path /ecard/:id for editing?Sechrist
Why are you using $location.path inside the then function of autosave? As I understand it, the location shouldn't change unless the tab changes. I would assume the autosave function is responding with a response from the current tab, so the id of response should be the same as the current id.Unpriced
@TylerEich: New eCards have no id. We could theoretically start every eCard with an id, but then there's a blank eCard draft if you open the compose form and then close it without making any changes. That's why we need to change the location from e.g. /ecard/new to /ecard/123.Balladeer
@Beyers: To clarify, the url for an existing eCard is e.g. /ecard/123 or /ecard/42 - I was using :id to indicate that we actually put the id in the path, which we then get with the $routeParams service.Balladeer
@EliCourtwright I understood that, what I'm asking is that you say you can have multiple ecards open for editing at the same time, so what does your url look like when you have multiple open cards at the same time?Sechrist
@Beyers: The current URL will always reflect the currently active tab. This is a custom (and very simple) tabbing system where changing tabs just changes the url from /ecard/123 to /ecard/42. The tabs themselves are basically just links with an href to #/ecard/:id.Balladeer
B
0

It turns out there's a way to do exactly what I want, although it's not officially blessed by Angular. Someone opened an Angular ticket for this exact use case: https://github.com/angular/angular.js/issues/1699

The proposed change was submitted as a pull request and rejected: https://github.com/angular/angular.js/pull/2398

Based on the comments in the original ticket, I implemented a workaround that looks like this:

app.factory('patchLocationWithSkipReload', function ($location, $route, $rootScope) {
    $location.skipReload = function () {
        var prevRoute = $route.current;
        var unregister = $rootScope.$on('$locationChangeSuccess', function () {
            $route.current = prevRoute;
            unregister();
        });
        return $location;
    };
});

I'm then able to basically (error handling omitted for brevity) say

ECardService.autosave($scope.eCard).then(function (response) {
    $location.skipReload().path('/ecard/' + response.id).replace();
    $scope.resetAutosaveTimeout();
});

Basic testing shows this works great!

Balladeer answered 14/3, 2014 at 21:44 Comment(0)
S
3

This is not angular way, but it can be useful. After receiving data you can check whether there is an focused element (user is typing). If so, then you need to define a function that is performed once when element lose focus. If no focused element, the change url immediately.

Like this:

ECardService.autosave($scope.eCard).then(function (response) {
    if($(':focus').length){ //if there is focused element
        $(':focus').one('blur', function(){ //
            $location.path('/ecard/' + response.id).replace(); //perform once
        });
    }
    else{
        $location.path('/ecard/' + response.id).replace();
    }
});

Of course this is not the most elegant solution, but it seems to solve your problem.

Standin answered 6/3, 2014 at 17:53 Comment(1)
Excellent idea; I'll have to test this to see whether the flicker from a refresh takes too long to make this feasible, but I like the idea behind this approach a lot more than my current timeout-until-user-idle strategy.Balladeer
F
2

If you have code that needs to run across multiple view controllers AngularJS provides a root scope for such instances. You can find the documentation here.

However I would recommend against having a tabbing system that is actually multiple views. Having multiple items open means to have them all in your work space.

You might want to consider a single view with Angular directives for your e-cards. That way they could each have their own scope and would be available at an instance without re-rendering the page.

They would also be able to share the functions defined in the controller's $scope, without the need for an app wide root scope. Note that scope has to be enabled on directives. scope: true

Check out the AngularJS site for tutorial and documentation on this.

Forcier answered 7/3, 2014 at 19:58 Comment(2)
An interesting idea. We chose multiple views because it lets the browser back/forward/reload buttons work as they'd expect, but you're correct that the problem presented by this question wouldn't be an issue if we went with your suggestion.Balladeer
Certainly something to weigh. I think I came up with a much better solution above.Forcier
T
1

It seems that the best solution for the problem you're describing would be to use a state machine like ui-router.

With a library like that one, you can have a single page app that has multiple states (that you can also make part of the url), so whenever the state changes, you can save your e-card and you'll never have any visible reloads because you're working on a single page application.

Tectrix answered 10/3, 2014 at 22:40 Comment(5)
I guess that such an approach will not help in this situation. Controller will continue to re render the page, and the input field will lose focus.Standin
It won't reload because it technically is a 1 page application, so you aren't really changing locations, you are changing states on an app that has already loaded in it's entirety.Tectrix
This function ( $location.path('/ecard/' + response.id).replace(); ) also does not reload the page. But the controller is initialized again. For this reason, the active input field loses focus.Standin
What you're saying makes sense, but it depends on the way you structure the state machine. Ui-router allows you to have nested controllers and you can have then take control of small templates that compose the larger view, so on state change a controller may get reloaded but not necessarily all of them.Tectrix
Yes, i familar with ui-router, but if you carefully read the question - then make sure that this approach will not help in the case of which the author described.Standin
F
0

So I understand the path wants to reflect the id of the latest version, so in that case you would need to refresh every save.

But, what about if the path was something like ecard/latest as a alias for the latest version. That way you wouldn't have to refresh your view since you don't have to change your path, and just implement something in the back-end directs the param latest to the id of the latest version.

Forcier answered 10/3, 2014 at 19:37 Comment(2)
Once it's been created, the id will be static and will no longer change. The reason why it has to change the first time is that until it's been created, there is no id. And to clarify, the path I'm referring to is the path after the hash mark (so the server never sees it), e.g. example.com/#/ecard/new.Balladeer
Ya, I understood you meant internal route. Hmm, that's a tough one. The only thing I could think of is save it as soon as they create the e-card, then have some logic to figure out weather to delete it if they never write anything to it, or maybe create a route param that puts the cursor back in place. But I would not see the reload as the problem, the app should re-render the view when the path changes.Forcier
B
0

It turns out there's a way to do exactly what I want, although it's not officially blessed by Angular. Someone opened an Angular ticket for this exact use case: https://github.com/angular/angular.js/issues/1699

The proposed change was submitted as a pull request and rejected: https://github.com/angular/angular.js/pull/2398

Based on the comments in the original ticket, I implemented a workaround that looks like this:

app.factory('patchLocationWithSkipReload', function ($location, $route, $rootScope) {
    $location.skipReload = function () {
        var prevRoute = $route.current;
        var unregister = $rootScope.$on('$locationChangeSuccess', function () {
            $route.current = prevRoute;
            unregister();
        });
        return $location;
    };
});

I'm then able to basically (error handling omitted for brevity) say

ECardService.autosave($scope.eCard).then(function (response) {
    $location.skipReload().path('/ecard/' + response.id).replace();
    $scope.resetAutosaveTimeout();
});

Basic testing shows this works great!

Balladeer answered 14/3, 2014 at 21:44 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.