Skip to content
This repository has been archived by the owner on Apr 12, 2024. It is now read-only.

fix($location): correctly handle external URL change during $digest #15561

Closed
wants to merge 2 commits into from

Conversation

gkalpak
Copy link
Member

@gkalpak gkalpak commented Dec 30, 2016

What kind of change does this PR introduce? (Bug fix, feature, docs update, ...)
Bug fix(es).

What is the current behavior? (You can also link to an open issue here)

  1. $browser.$$checkUrlChange() (which was run before each $digest) will only detect an external change (i.e. not via $location) to the browser URL. External changes to history.state will not be detected and propagated to $location.

    This would not be a problem if changes were followed by a popstate or hashchange event (which would call cacheStateAndFireUrlChange()). But since history.pushState()/replaceState() do not fire any events, calling these methods manually will result in $location getting out-of-sync with the actual
    history state.

    This is not detected in tests, because the mocked window.history will incorrectly trigger popstate when calling pushState()/replaceState(), which "covers" the bug.

  2. When the URL is changed directly (e.g. via location.href) during a $digest (e.g. via scope.$evalAsync() or promise.then()) the change is not handled correctly, unless a popstate or hashchange event is fired synchronously.

    This is an issue when calling history.pushState()/replaceState() in all browsers, since these methods do not emit any event. This is also an issue when setting location.href in IE11, where (unlike other browsers) no popstate event is fired at all for hash-only changes (known bug) and the hashchange event is fired asynchronously (which is too late).

What is the new behavior (if this is a feature change)?

  1. The first bug is fixed by ensuring cacheState() is always called, before looking for and propagating a URL/state change.

  2. The second bug(s) is fixed by:

    1. Keeping track of $location setter methods being called and only processing a URL change if it originated from such a call. If there is a URL difference but no setter method has been called, this means that the browser URL/history has been updated directly and the change hasn't yet been propagated to $location (e.g. due to no event being fired synchronously or at all).
    2. Checking for URL/state changes at the end of the $digest, in order to detect changes via history methods (that took place during the $digest).

Does this PR introduce a breaking change?
Who kNOws?
(This PR fixes some uncommon usecases, but I am not 100% sure it was not possible to somehow "exploit" the bugs to support a usecase that is now broken.)

Please check if the PR fulfills these requirements

Other information:
Fixes #11075, #12571 and #15556.

Previously, `$browser.$$checkUrlChange()` (which was run before each `$digest`)
would only detect an external change (i.e. not via `$location`) to the browser
URL. External changes to `history.state` would not be detected and propagated to
`$location`.

This would not be a problem if changes were followed by a `popstate` or
`hashchange` event (which would call `cacheStateAndFireUrlChange()`). But since
`history.pushState()/replaceState()` do not fire any events, calling these
methods manually would result in `$location` getting out-of-sync with the actual
history state.

This was not detected in tests, because the mocked `window.history` would
incorrectly trigger `popstate` when calling `pushState()/replaceState()`, which
"covered" the bug.

This commit fixes it by always calling `cacheState()`, before looking for and
propagating a URL/state change.
Previously, when the URL was changed directly (e.g. via `location.href`) during
a `$digest` (e.g. via `scope.$evalAsync()` or `promise.then()`) the change was
not handled correctly, unless a `popstate` or `hashchange` event was fired
synchronously.

This was an issue when calling `history.pushState()/replaceState()` in all
browsers, since these methods do not emit any event. This was also an issue when
setting `location.href` in IE11, where (unlike other browsers) no `popstate`
event is fired at all for hash-only changes ([known bug][1]) and the
`hashchange` event is fired asynchronously (which is too late).

This commit fixes both usecases by:

1. Keeping track of `$location` setter methods being called and only processing
   a URL change if it originated from such a call. If there is a URL difference
   but no setter method has been called, this means that the browser URL/history
   has been updated directly and the change hasn't yet been propagated to
   `$location` (e.g. due to no event being fired synchronously or at all).
2. Checking for URL/state changes at the end of the `$digest`, in order to
   detect changes via `history` methods (that took place during the `$digest`).

[1]: https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/3740423/

Fixes angular#11075
Fixes angular#12571
Fixes angular#15556
Copy link
Member

@petebacondarwin petebacondarwin left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This sounds like a reasonable change.
The fact that the tests were artificially hiding the real behaviour was worrying (probably my fault).
Are we concerned that we now need a digest for the location to be updated - or was it the case that this was just broken completely previously, so waiting for a digest is a step forward?

@gkalpak
Copy link
Member Author

gkalpak commented Jan 4, 2017

Wow, that was quick 😃

Are we concerned that we now need a digest for the location to be updated - or was it the case that this was just broken completely previously, so waiting for a digest is a step forward?

The latter. $location will still be updated at the same point in time for cases that worked previously.
Previously, there was a "family" of external changes that would either go unnoticed (e.g. changes to history.state only) or cause an infinite digest error (e.g. changes to location.href in IE11 or history.pushState(...) in all browsers). Now, such changes will not cause infinite digest errors and will get noticed (but it is possible that they will immediately trigger another $digest).

I am a (tiny) little concerned that people might somehow rely on history.state changes being unnoticed and using that to support their obscure usecases 😃

@petebacondarwin
Copy link
Member

I don't that is a breaking change. It was not documented to do that and it is very reasonable that the previous functionality is wrong.

@gkalpak gkalpak closed this in 752f411 Jan 9, 2017
gkalpak added a commit that referenced this pull request Jan 9, 2017
Previously, when the URL was changed directly (e.g. via `location.href`) during
a `$digest` (e.g. via `scope.$evalAsync()` or `promise.then()`) the change was
not handled correctly, unless a `popstate` or `hashchange` event was fired
synchronously.

This was an issue when calling `history.pushState()/replaceState()` in all
browsers, since these methods do not emit any event. This was also an issue when
setting `location.href` in IE11, where (unlike other browsers) no `popstate`
event is fired at all for hash-only changes ([known bug][1]) and the
`hashchange` event is fired asynchronously (which is too late).

This commit fixes both usecases by:

1. Keeping track of `$location` setter methods being called and only processing
   a URL change if it originated from such a call. If there is a URL difference
   but no setter method has been called, this means that the browser URL/history
   has been updated directly and the change hasn't yet been propagated to
   `$location` (e.g. due to no event being fired synchronously or at all).
2. Checking for URL/state changes at the end of the `$digest`, in order to
   detect changes via `history` methods (that took place during the `$digest`).

[1]: https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/3740423/

Fixes #11075
Fixes #12571
Fixes #15556

Closes #15561
@gkalpak
Copy link
Member Author

gkalpak commented Jan 9, 2017

I've merged this into master and v1.6.x. I thought it might be too much for v1.5.x (considering it is a non-trivial change/fix), but could be convinced otherwise 😃

@gkalpak gkalpak deleted the fix-location-when-no-popstate branch January 11, 2017 21:51
ellimist pushed a commit to ellimist/angular.js that referenced this pull request Mar 15, 2017
Previously, when the URL was changed directly (e.g. via `location.href`) during
a `$digest` (e.g. via `scope.$evalAsync()` or `promise.then()`) the change was
not handled correctly, unless a `popstate` or `hashchange` event was fired
synchronously.

This was an issue when calling `history.pushState()/replaceState()` in all
browsers, since these methods do not emit any event. This was also an issue when
setting `location.href` in IE11, where (unlike other browsers) no `popstate`
event is fired at all for hash-only changes ([known bug][1]) and the
`hashchange` event is fired asynchronously (which is too late).

This commit fixes both usecases by:

1. Keeping track of `$location` setter methods being called and only processing
   a URL change if it originated from such a call. If there is a URL difference
   but no setter method has been called, this means that the browser URL/history
   has been updated directly and the change hasn't yet been propagated to
   `$location` (e.g. due to no event being fired synchronously or at all).
2. Checking for URL/state changes at the end of the `$digest`, in order to
   detect changes via `history` methods (that took place during the `$digest`).

[1]: https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/3740423/

Fixes angular#11075
Fixes angular#12571
Fixes angular#15556

Closes angular#15561
@dlongley
Copy link
Contributor

@gkalpak,

I am a (tiny) little concerned that people might somehow rely on history.state changes being unnoticed and using that to support their obscure usecases...

You were right! :)

I was relying on this behavior to ensure that I could hit the back button after opening modals that should not trigger route changes. I was previously adding a new history state and checking for its presence to determine whether or not the back button should close a modal or actually go back in history. It worked great in Angular 1.5.

With this patch now (in Angular 1.6) route changes are triggered after you push the custom history state -- and if you try and cancel them via preventDefault, the $browser code mangles the history. I'm hoping to discover a workaround to restore the functionality.

@petebacondarwin
Copy link
Member

@dlongley - sorry about this. If you don't find a workaround please post again and we can see what we can do to fix it.

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Bug: Changing the location outside of $location causes infdig and/or resets the original url
4 participants